From 917a41e85d9dad22392de53c5eab313ea059e62a Mon Sep 17 00:00:00 2001 From: David Ansari Date: Sun, 19 Sep 2021 18:03:41 +0200 Subject: [PATCH] Support password iterations != 100100 Closes #44 As reported in https://github.com/lastpass/lastpass-cli/issues/604, on 18 May 2021 behaviour changed in LastPass servers. Prior to that date, password iteration != 100100 was supported by this lib by first POSTing to the /iterations.php endpoint to get the correct iterations count. This commit fixes the broken /iterations.php behaviour as explained in https://github.com/detunized/password-manager-access/commit/bd2e31d3478309a2d9a5e171b0422a1a254c3a57#diff-708eab38b171b2961f6da413413fd63d1cff3d5fceda920289959678be35a184R51-R58: "We no longer request the iteration count from the server in a separate request because it started to fail in weird ways. It seems there's a special combination of the User Agent and cookies that returns the correct result. And that is not 100% reliable. After two or three attempts it starts to fail again with an incorrect result. So we just went back a few years to the original way LastPass used to handle the iterations. Namely, submit the default value and if it fails, the error would contain the correct value: " So, we first try to login with the default 100100 iterations. If it fails, we try to login again with the iterations from the error message. --- account_test.go | 24 +- client.go | 3 +- client_test.go | 729 +++++++++++++++++++++++------------------------- log_test.go | 10 +- session.go | 65 ++--- 5 files changed, 396 insertions(+), 435 deletions(-) diff --git a/account_test.go b/account_test.go index e4ec1aa..c163f99 100644 --- a/account_test.go +++ b/account_test.go @@ -26,10 +26,6 @@ var _ = Describe("Account", func() { passwd = readFile("passwd.txt") server = ghttp.NewServer() server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointIterations), - ghttp.RespondWith(http.StatusOK, "100100"), - ), ghttp.CombineHandlers( ghttp.VerifyRequest(http.MethodPost, EndpointLogin), ghttp.RespondWith(http.StatusOK, @@ -97,8 +93,8 @@ var _ = Describe("Account", func() { LastTouch: "1566373938", }, )) - // /iterations.php, /login.php, /login_check.php, /getaccts.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + // /login.php, /login_check.php, /getaccts.php + Expect(server.ReceivedRequests()).To(HaveLen(3)) }) }) When("group accounts are returned", func() { @@ -109,8 +105,8 @@ var _ = Describe("Account", func() { accts, err := client.Accounts(context.Background()) Expect(err).NotTo(HaveOccurred()) Expect(accts).To(BeEmpty()) - // /iterations.php, /login.php, /login_check.php, /getaccts.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + // /login.php, /login_check.php, /getaccts.php + Expect(server.ReceivedRequests()).To(HaveLen(3)) }) }) When("shared folders exist whose sharing key is AES encrypted with user's encryption key", func() { @@ -169,8 +165,8 @@ var _ = Describe("Account", func() { LastTouch: "0", }, )) - // /iterations.php, /login.php, /login_check.php, /getaccts.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + // /login.php, /login_check.php, /getaccts.php + Expect(server.ReceivedRequests()).To(HaveLen(3)) }) }) When("shared folder exists whose sharing key needs to be decrypted with user's RSA private key", func() { @@ -194,8 +190,8 @@ var _ = Describe("Account", func() { LastTouch: "0", }, )) - // /iterations.php, /login.php, /login_check.php, /getaccts.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + // /login.php, /login_check.php, /getaccts.php + Expect(server.ReceivedRequests()).To(HaveLen(3)) }) }) When("an account is AES 256 ECB encrypted", func() { @@ -218,8 +214,8 @@ var _ = Describe("Account", func() { LastTouch: "0", }, )) - // /iterations.php, /login.php, /login_check.php, /getaccts.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + // /login.php, /login_check.php, /getaccts.php + Expect(server.ReceivedRequests()).To(HaveLen(3)) }) }) When("blob is not base 64 encoded", func() { diff --git a/client.go b/client.go index f767c9f..ac91e94 100644 --- a/client.go +++ b/client.go @@ -23,7 +23,6 @@ const ( EndpointLogin = "/login.php" EndpointTrust = "/trust.php" EndpointLoginCheck = "/login_check.php" - EndpointIterations = "/iterations.php" EndpointGetAccts = "/getaccts.php" EndpointShowWebsite = "/show_website.php" EndpointLogout = "/logout.php" @@ -94,7 +93,7 @@ func NewClient(ctx context.Context, username, masterPassword string, opts ...Cli if err = c.calculateTrustLabel(); err != nil { return nil, err } - if err = c.initSession(ctx, masterPassword); err != nil { + if err = c.login(ctx, masterPassword); err != nil { return nil, err } return c, nil diff --git a/client_test.go b/client_test.go index 8fdc25f..78a4b1a 100644 --- a/client_test.go +++ b/client_test.go @@ -87,7 +87,7 @@ var _ = Describe("Client", func() { When("NewClient()", func() { var loginForm url.Values - var user, passwd, passwdIterations string + var user, passwd string contentTypeVerifier := ghttp.VerifyContentType("application/x-www-form-urlencoded") BeforeEach(func() { @@ -98,6 +98,7 @@ var _ = Describe("Client", func() { loginForm.Set("method", "cli") loginForm.Set("xml", "1") loginForm.Set("username", user) + loginForm.Set("iterations", "100100") }) When("username is empty", func() { @@ -115,20 +116,18 @@ var _ = Describe("Client", func() { Context("with 1 password iteration", func() { BeforeEach(func() { - passwdIterations = "1" - loginForm.Set("iterations", passwdIterations) respLoginCheck := ` ` server.AppendHandlers( ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointIterations), + ghttp.VerifyRequest(http.MethodPost, EndpointLogin), contentTypeVerifier, - ghttp.VerifyFormKV("email", user), - ghttp.RespondWith(http.StatusOK, passwdIterations), + ghttp.VerifyForm(loginForm), + ghttp.RespondWith(http.StatusOK, ""), ), ghttp.CombineHandlers( ghttp.VerifyRequest(http.MethodPost, EndpointLogin), contentTypeVerifier, - ghttp.VerifyForm(loginForm), + ghttp.VerifyFormKV("iterations", "1"), ghttp.RespondWith(http.StatusOK, fmt.Sprintf("", "fakeToken", readFile("privatekeyencrypted-1iteration.txt"))), ), @@ -158,501 +157,485 @@ var _ = Describe("Client", func() { LastTouch: "1566374009", }, )) - // /iterations.php, /login.php, /login_check.php, /getaccts.php + // /login.php, /login.php, /login_check.php, /getaccts.php Expect(server.ReceivedRequests()).To(HaveLen(4)) }) }) - Context("with default password iterations", func() { - BeforeEach(func() { - passwdIterations = "100100" - loginForm.Set("iterations", passwdIterations) - + When("authentication fails", func() { + var cause string + var msg string + var rsp string + JustBeforeEach(func() { server.AppendHandlers( ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointIterations), + ghttp.VerifyRequest(http.MethodPost, EndpointLogin), contentTypeVerifier, - ghttp.VerifyFormKV("email", user), - ghttp.RespondWith(http.StatusOK, passwdIterations), + ghttp.VerifyForm(loginForm), + ghttp.RespondWith(http.StatusOK, rsp), ), ) }) - - When("authentication fails", func() { - var cause string - var msg string - var rsp string - JustBeforeEach(func() { - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointLogin), - contentTypeVerifier, - ghttp.VerifyForm(loginForm), - ghttp.RespondWith(http.StatusOK, rsp), - ), - ) + Context("due to invalid email or password", func() { + BeforeEach(func() { + cause = "unknown" + msg = "Invalid email or password!" + rsp = fmt.Sprintf("", + msg, cause, user) }) - Context("due to invalid email or password", func() { - BeforeEach(func() { - cause = "unknown" - msg = "Invalid email or password!" - rsp = fmt.Sprintf("", - msg, cause, user) - }) - It("returns AuthenticationError", func() { - client, err := NewClient(context.Background(), user, passwd, WithBaseURL(server.URL())) - Expect(client).To(BeNil()) - Expect(err).To(MatchError(fmt.Sprintf("%s: %s", cause, msg))) - _, ok := err.(*AuthenticationError) - Expect(ok).To(BeTrue()) - // /iterations.php, /login.php - Expect(server.ReceivedRequests()).To(HaveLen(2)) - }) + It("returns AuthenticationError", func() { + client, err := NewClient(context.Background(), user, passwd, WithBaseURL(server.URL())) + Expect(client).To(BeNil()) + Expect(err).To(MatchError(fmt.Sprintf("%s: %s", cause, msg))) + _, ok := err.(*AuthenticationError) + Expect(ok).To(BeTrue()) + // /login.php + Expect(server.ReceivedRequests()).To(HaveLen(1)) }) - Context("due to missing out-of-band approval", func() { - var retryID string - var loginRetryForm url.Values - BeforeEach(func() { - cause = "outofbandrequired" - msg = "Multifactor authentication required!" - retryID = "123" - rsp = fmt.Sprintf("", - msg, cause, retryID) - }) + }) + Context("due to missing out-of-band approval", func() { + var retryID string + var loginRetryForm url.Values + BeforeEach(func() { + cause = "outofbandrequired" + msg = "Multifactor authentication required!" + retryID = "123" + rsp = fmt.Sprintf("", + msg, cause, retryID) + }) + JustBeforeEach(func() { + loginRetryForm = url.Values{} + for k, v := range loginForm { + loginRetryForm[k] = v + } + loginRetryForm.Set("outofbandrequest", "1") + loginRetryForm.Set("outofbandretry", "1") + loginRetryForm.Set("outofbandretryid", retryID) + }) + Context("until MaxLoginRetries is reached", func() { JustBeforeEach(func() { - loginRetryForm = url.Values{} - for k, v := range loginForm { - loginRetryForm[k] = v - } - loginRetryForm.Set("outofbandrequest", "1") - loginRetryForm.Set("outofbandretry", "1") - loginRetryForm.Set("outofbandretryid", retryID) - }) - Context("until MaxLoginRetries is reached", func() { - JustBeforeEach(func() { - for i := 0; i < MaxLoginRetries; i++ { - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointLogin), - contentTypeVerifier, - ghttp.VerifyForm(loginRetryForm), - ghttp.RespondWith(http.StatusOK, rsp), - ), - ) - } - }) - It("returns AuthenticationError", func() { - client, err := NewClient(context.Background(), user, passwd, WithBaseURL(server.URL())) - Expect(client).To(BeNil()) - Expect(err).To(MatchError(MatchRegexp(`^didn't receive out-of-band approval within the last \d seconds$`))) - // /iterations.php, /login.php, MaxLoginRetries * /login/php - Expect(server.ReceivedRequests()).To(HaveLen(2 + MaxLoginRetries)) - }) - }) - When("re-trying due to unknown error", func() { - var retryCause, retryMsg string - JustBeforeEach(func() { - retryMsg = "unknown" - retryCause = "some cause" + for i := 0; i < MaxLoginRetries; i++ { server.AppendHandlers( ghttp.CombineHandlers( ghttp.VerifyRequest(http.MethodPost, EndpointLogin), contentTypeVerifier, ghttp.VerifyForm(loginRetryForm), - ghttp.RespondWith(http.StatusOK, - fmt.Sprintf("", retryMsg, retryCause)), + ghttp.RespondWith(http.StatusOK, rsp), ), ) - }) - It("returns AuthenticationError", func() { - client, err := NewClient(context.Background(), user, passwd, WithBaseURL(server.URL())) - Expect(client).To(BeNil()) - Expect(err).To(MatchError(fmt.Sprintf("%s: %s", retryCause, retryMsg))) - // /iterations.php, /login.php, /login/php - Expect(server.ReceivedRequests()).To(HaveLen(3)) - }) + } + }) + It("returns AuthenticationError", func() { + client, err := NewClient(context.Background(), user, passwd, WithBaseURL(server.URL())) + Expect(client).To(BeNil()) + Expect(err).To(MatchError(MatchRegexp(`^didn't receive out-of-band approval within the last \d seconds$`))) + // /login.php, MaxLoginRetries * /login/php + Expect(server.ReceivedRequests()).To(HaveLen(1 + MaxLoginRetries)) + }) + }) + When("re-trying due to unknown error", func() { + var retryCause, retryMsg string + JustBeforeEach(func() { + retryMsg = "unknown" + retryCause = "some cause" + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, EndpointLogin), + contentTypeVerifier, + ghttp.VerifyForm(loginRetryForm), + ghttp.RespondWith(http.StatusOK, + fmt.Sprintf("", retryMsg, retryCause)), + ), + ) + }) + It("returns AuthenticationError", func() { + client, err := NewClient(context.Background(), user, passwd, WithBaseURL(server.URL())) + Expect(client).To(BeNil()) + Expect(err).To(MatchError(fmt.Sprintf("%s: %s", retryCause, retryMsg))) + // /login.php, /login/php + Expect(server.ReceivedRequests()).To(HaveLen(2)) }) }) }) + }) + + When("NewClient() succeeds", func() { + var form url.Values + const token = "fakeToken" + const otp = "654321" + + BeforeEach(func() { + privateKeyEncrypted := readFile("privatekeyencrypted.txt") + loginForm.Set("otp", otp) - When("NewClient() succeeds", func() { - var form url.Values - const token = "fakeToken" - const otp = "654321" + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, EndpointLogin), + contentTypeVerifier, + ghttp.VerifyForm(loginForm), + ghttp.RespondWith(http.StatusOK, fmt.Sprintf("", + token, privateKeyEncrypted)), + ), + ) + }) + + Context("trust", func() { + var configDir, trustIDFile, trustLabel string BeforeEach(func() { - privateKeyEncrypted := readFile("privatekeyencrypted.txt") - loginForm.Set("otp", otp) - - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointLogin), - contentTypeVerifier, - ghttp.VerifyForm(loginForm), - ghttp.RespondWith(http.StatusOK, fmt.Sprintf("", - token, privateKeyEncrypted)), - ), - ) + var err error + configDir, err = ioutil.TempDir("", "lastpass-go-unit-test") + Expect(err).ToNot(HaveOccurred()) + trustIDFile = filepath.Join(configDir, "trusted_id") + + hostname, err := os.Hostname() + Expect(err).NotTo(HaveOccurred()) + trustLabel = fmt.Sprintf("%s %s %s", hostname, runtime.GOOS, "lastpass-go") }) - Context("trust", func() { - var configDir, trustIDFile, trustLabel string + AfterEach(func() { + Expect(os.RemoveAll(configDir)).To(Succeed()) + }) - BeforeEach(func() { - var err error - configDir, err = ioutil.TempDir("", "lastpass-go-unit-test") - Expect(err).ToNot(HaveOccurred()) - trustIDFile = filepath.Join(configDir, "trusted_id") + When("trusted_id file is present", func() { + const fakeTrustID string = "!@#$0123456789abcdexyzABCDEFGXYZ" - hostname, err := os.Hostname() - Expect(err).NotTo(HaveOccurred()) - trustLabel = fmt.Sprintf("%s %s %s", hostname, runtime.GOOS, "lastpass-go") + BeforeEach(func() { + Expect(ioutil.WriteFile(trustIDFile, []byte(fakeTrustID), 0600)).To(Succeed()) }) AfterEach(func() { - Expect(os.RemoveAll(configDir)).To(Succeed()) + trustID, err := ioutil.ReadFile(trustIDFile) + Expect(err).NotTo(HaveOccurred()) + Expect(string(trustID)).To(Equal(fakeTrustID), "trusted_id file should not be modified") }) - When("trusted_id file is present", func() { - const fakeTrustID string = "!@#$0123456789abcdexyzABCDEFGXYZ" - - BeforeEach(func() { - Expect(ioutil.WriteFile(trustIDFile, []byte(fakeTrustID), 0600)).To(Succeed()) - }) - - AfterEach(func() { - trustID, err := ioutil.ReadFile(trustIDFile) + When("WithTrust() option is not set", func() { + It("requests only /login.php, but not /trust.php", func() { + var err error + client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL()), + WithConfigDir(configDir), + ) Expect(err).NotTo(HaveOccurred()) - Expect(string(trustID)).To(Equal(fakeTrustID), "trusted_id file should not be modified") - }) - - When("WithTrust() option is not set", func() { - It("requests only /iterations.php and /login.php, but not /trust.php", func() { - var err error - client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL()), - WithConfigDir(configDir), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(server.ReceivedRequests()).To(HaveLen(2)) - }) - It("uses trust ID to log in", func() { - loginForm.Set("uuid", fakeTrustID) - - var err error - client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL()), - WithConfigDir(configDir), - ) - Expect(err).NotTo(HaveOccurred()) - }) + Expect(server.ReceivedRequests()).To(HaveLen(1)) }) + It("uses trust ID to log in", func() { + loginForm.Set("uuid", fakeTrustID) - When("WithTrust() option is set", func() { - It("uses trust ID to login and posts to /trust.php endpoint to update the label", func() { - loginForm.Set("uuid", fakeTrustID) - loginForm.Set("trustlabel", trustLabel) - - trustForm := url.Values{} - trustForm.Set("uuid", fakeTrustID) - trustForm.Set("token", token) - trustForm.Set("trustlabel", trustLabel) - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointTrust), - contentTypeVerifier, - ghttp.VerifyForm(trustForm), - ghttp.RespondWith(http.StatusOK, nil), - ), - ) - - var err error - client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL()), - WithConfigDir(configDir), - WithTrust(), - ) - Expect(err).NotTo(HaveOccurred()) - }) + var err error + client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL()), + WithConfigDir(configDir), + ) + Expect(err).NotTo(HaveOccurred()) }) }) - When("trusted_id file is absent and WithTrust() option is set", func() { - It("creates a new trust ID", func() { - By("posting the new trust label to /trust.php endpoint") + When("WithTrust() option is set", func() { + It("uses trust ID to login and posts to /trust.php endpoint to update the label", func() { + loginForm.Set("uuid", fakeTrustID) + loginForm.Set("trustlabel", trustLabel) + + trustForm := url.Values{} + trustForm.Set("uuid", fakeTrustID) + trustForm.Set("token", token) + trustForm.Set("trustlabel", trustLabel) server.AppendHandlers( ghttp.CombineHandlers( ghttp.VerifyRequest(http.MethodPost, EndpointTrust), contentTypeVerifier, - ghttp.VerifyFormKV("token", token), - ghttp.VerifyFormKV("trustlabel", trustLabel), - // form value for generated uuid is not known in advance and therefore tested at the end of this test case + ghttp.VerifyForm(trustForm), ghttp.RespondWith(http.StatusOK, nil), ), ) - Expect(trustIDFile).ToNot(BeAnExistingFile()) - var err error client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL()), WithConfigDir(configDir), WithTrust(), ) Expect(err).NotTo(HaveOccurred()) - - By("creating a new trusted_id file") - Expect(trustIDFile).To(BeARegularFile()) - uuid, err := ioutil.ReadFile(trustIDFile) - Expect(err).NotTo(HaveOccurred()) - Expect(uuid).To(MatchRegexp(`^[a-zA-Z0-9!@#\$]{32}$`)) - - By("making the new file only accessible to the user") - fileInfo, err := os.Stat(trustIDFile) - Expect(err).NotTo(HaveOccurred()) - Expect(fileInfo.Mode()).To(Equal(os.FileMode(0600))) - - By("posting the new trust ID to /trust.php endpoint") - // /iterations.php, /login.php, /trust.php - Expect(server.ReceivedRequests()).To(HaveLen(3)) - trustRequest := server.ReceivedRequests()[2] - Expect(trustRequest.FormValue("uuid")).To(Equal(string(uuid))) }) }) }) - Context("no trust, i.e. neither WithTrust() option is set nor trusted_id file exists", func() { - BeforeEach(func() { + When("trusted_id file is absent and WithTrust() option is set", func() { + It("creates a new trust ID", func() { + By("posting the new trust label to /trust.php endpoint") + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, EndpointTrust), + contentTypeVerifier, + ghttp.VerifyFormKV("token", token), + ghttp.VerifyFormKV("trustlabel", trustLabel), + // form value for generated uuid is not known in advance and therefore tested at the end of this test case + ghttp.RespondWith(http.StatusOK, nil), + ), + ) + + Expect(trustIDFile).ToNot(BeAnExistingFile()) + var err error - client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL())) + client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL()), + WithConfigDir(configDir), + WithTrust(), + ) + Expect(err).NotTo(HaveOccurred()) + + By("creating a new trusted_id file") + Expect(trustIDFile).To(BeARegularFile()) + uuid, err := ioutil.ReadFile(trustIDFile) Expect(err).NotTo(HaveOccurred()) + Expect(uuid).To(MatchRegexp(`^[a-zA-Z0-9!@#\$]{32}$`)) + + By("making the new file only accessible to the user") + fileInfo, err := os.Stat(trustIDFile) + Expect(err).NotTo(HaveOccurred()) + Expect(fileInfo.Mode()).To(Equal(os.FileMode(0600))) + + By("posting the new trust ID to /trust.php endpoint") + // /login.php, /trust.php + Expect(server.ReceivedRequests()).To(HaveLen(2)) + trustRequest := server.ReceivedRequests()[1] + Expect(trustRequest.FormValue("uuid")).To(Equal(string(uuid))) }) + }) + }) - Describe("NewClient()", func() { - It("requests /iterations.php and /login.php", func() { - Expect(server.ReceivedRequests()).To(HaveLen(2)) - }) + Context("no trust, i.e. neither WithTrust() option is set nor trusted_id file exists", func() { + BeforeEach(func() { + var err error + client, err = NewClient(context.Background(), user, passwd, WithOneTimePassword(otp), WithBaseURL(server.URL())) + Expect(err).NotTo(HaveOccurred()) + }) + + Describe("NewClient()", func() { + It("requests /login.php", func() { + Expect(server.ReceivedRequests()).To(HaveLen(1)) }) + }) - When("session is live", func() { - rsp := ` ` - BeforeEach(func() { + When("session is live", func() { + rsp := ` ` + BeforeEach(func() { + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, EndpointLoginCheck), + ghttp.RespondWith(http.StatusOK, rsp), + ), + ) + }) + When("successfully operating on a single account", func() { + var rspMsg string + JustBeforeEach(func() { server.AppendHandlers( ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointLoginCheck), - ghttp.RespondWith(http.StatusOK, rsp), + ghttp.VerifyRequest(http.MethodPost, EndpointShowWebsite), + contentTypeVerifier, + ghttp.VerifyForm(form), + ghttp.RespondWith(http.StatusOK, fmt.Sprintf( + "", + acct.ID, rspMsg), + ), ), ) }) - When("successfully operating on a single account", func() { - var rspMsg string - JustBeforeEach(func() { - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointShowWebsite), - contentTypeVerifier, - ghttp.VerifyForm(form), - ghttp.RespondWith(http.StatusOK, fmt.Sprintf( - "", - acct.ID, rspMsg), - ), - ), - ) - }) - AfterEach(func() { - // /iterations.php, /login.php, /login_check.php, /show_website.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + AfterEach(func() { + // /login.php, /login_check.php, /show_website.php + Expect(server.ReceivedRequests()).To(HaveLen(3)) + }) + When("upserting", func() { + BeforeEach(func() { + form = url.Values{} + form.Set("method", "cli") + form.Set("extjs", "1") + form.Set("token", token) + form.Set("url", hex.EncodeToString([]byte(acct.URL))) + form.Set("pwprotect", "off") }) - When("upserting", func() { + Describe("Add()", func() { BeforeEach(func() { - form = url.Values{} - form.Set("method", "cli") - form.Set("extjs", "1") - form.Set("token", token) - form.Set("url", hex.EncodeToString([]byte(acct.URL))) - form.Set("pwprotect", "off") + form.Set("aid", "0") }) - Describe("Add()", func() { + When("server returns 'accountadded'", func() { BeforeEach(func() { - form.Set("aid", "0") - }) - When("server returns 'accountadded'", func() { - BeforeEach(func() { - rspMsg = "accountadded" - }) - It("requests /show_website.php with aid=0 and sets account ID correctly", func() { - acct.ID = "ignored" - Expect(client.Add(context.Background(), acct)).To(Succeed()) - Expect(acct.ID).To(Equal("test ID")) - }) + rspMsg = "accountadded" }) - When("server does not return 'accountadded'", func() { - BeforeEach(func() { - rspMsg = "not added" - }) - It("returns a descriptive error", func() { - Expect(client.Add(context.Background(), acct)).To(MatchError("failed to add account")) - }) + It("requests /show_website.php with aid=0 and sets account ID correctly", func() { + acct.ID = "ignored" + Expect(client.Add(context.Background(), acct)).To(Succeed()) + Expect(acct.ID).To(Equal("test ID")) }) }) - Describe("Update()", func() { + When("server does not return 'accountadded'", func() { BeforeEach(func() { - form.Set("aid", acct.ID) - }) - When("server returns 'accountupdated'", func() { - BeforeEach(func() { - rspMsg = "accountupdated" - }) - It("requests /show_website.php with correct aid", func() { - Expect(client.Update(context.Background(), acct)).To(Succeed()) - }) + rspMsg = "not added" }) - When("server does not return 'accountupdated'", func() { - BeforeEach(func() { - rspMsg = "not updated" - }) - It("returns a descriptive error", func() { - Expect(client.Update(context.Background(), acct)).To(MatchError( - fmt.Sprintf("failed to update account (ID=%s)", acct.ID))) - }) + It("returns a descriptive error", func() { + Expect(client.Add(context.Background(), acct)).To(MatchError("failed to add account")) }) }) }) - Describe("Delete()", func() { + Describe("Update()", func() { BeforeEach(func() { - form = url.Values{} - form.Set("delete", "1") - form.Set("extjs", "1") - form.Set("token", token) form.Set("aid", acct.ID) }) - When("server returns 'accountdeleted'", func() { + When("server returns 'accountupdated'", func() { BeforeEach(func() { - rspMsg = "accountdeleted" + rspMsg = "accountupdated" }) - It("requests /show_website.php with correct aid and delete=1", func() { - Expect(client.Delete(context.Background(), acct)).To(Succeed()) + It("requests /show_website.php with correct aid", func() { + Expect(client.Update(context.Background(), acct)).To(Succeed()) }) }) - When("server does not return 'accountdeleted'", func() { + When("server does not return 'accountupdated'", func() { BeforeEach(func() { - rspMsg = "not deleted" + rspMsg = "not updated" }) It("returns a descriptive error", func() { - Expect(client.Delete(context.Background(), acct)).To(MatchError( - fmt.Sprintf("failed to delete account (ID=%s)", acct.ID))) + Expect(client.Update(context.Background(), acct)).To(MatchError( + fmt.Sprintf("failed to update account (ID=%s)", acct.ID))) }) }) }) }) - When("account does not exist", func() { + Describe("Delete()", func() { BeforeEach(func() { - header := http.Header{} - header.Set("Content-Length", "0") - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointShowWebsite), - ghttp.RespondWith(http.StatusOK, nil, header), - ), - ) - }) - AfterEach(func() { - // /iterations.php, /login.php, /login_check.php, /show_website.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + form = url.Values{} + form.Set("delete", "1") + form.Set("extjs", "1") + form.Set("token", token) + form.Set("aid", acct.ID) }) - Describe("Update()", func() { - It("returns AccountNotFoundError", func() { - Expect(client.Update(context.Background(), acct)).To(MatchError(&AccountNotFoundError{acct.ID})) + When("server returns 'accountdeleted'", func() { + BeforeEach(func() { + rspMsg = "accountdeleted" + }) + It("requests /show_website.php with correct aid and delete=1", func() { + Expect(client.Delete(context.Background(), acct)).To(Succeed()) }) }) - Describe("Delete()", func() { - It("returns AccountNotFoundError", func() { - acct := &Account{ID: "notExisting"} - Expect(client.Delete(context.Background(), acct)).To( - MatchError(&AccountNotFoundError{acct.ID})) + When("server does not return 'accountdeleted'", func() { + BeforeEach(func() { + rspMsg = "not deleted" + }) + It("returns a descriptive error", func() { + Expect(client.Delete(context.Background(), acct)).To(MatchError( + fmt.Sprintf("failed to delete account (ID=%s)", acct.ID))) }) }) }) + }) + When("account does not exist", func() { + BeforeEach(func() { + header := http.Header{} + header.Set("Content-Length", "0") + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, EndpointShowWebsite), + ghttp.RespondWith(http.StatusOK, nil, header), + ), + ) + }) + AfterEach(func() { + // /login.php, /login_check.php, /show_website.php + Expect(server.ReceivedRequests()).To(HaveLen(3)) + }) + Describe("Update()", func() { + It("returns AccountNotFoundError", func() { + Expect(client.Update(context.Background(), acct)).To(MatchError(&AccountNotFoundError{acct.ID})) + }) + }) + Describe("Delete()", func() { + It("returns AccountNotFoundError", func() { + acct := &Account{ID: "notExisting"} + Expect(client.Delete(context.Background(), acct)).To( + MatchError(&AccountNotFoundError{acct.ID})) + }) + }) + }) - When("HTTP error response", func() { - var err error - var path string + When("HTTP error response", func() { + var err error + var path string + BeforeEach(func() { + server.AppendHandlers( + ghttp.RespondWith(http.StatusInternalServerError, ""), + ) + }) + AfterEach(func() { + Expect(err).To(MatchError(MatchRegexp( + `POST http://127\.0\.0\.1:[0-9]{1,5}` + path + `: 500 Internal Server Error$`))) + }) + Context("returned by /show_website.php", func() { BeforeEach(func() { - server.AppendHandlers( - ghttp.RespondWith(http.StatusInternalServerError, ""), - ) - }) - AfterEach(func() { - Expect(err).To(MatchError(MatchRegexp( - `POST http://127\.0\.0\.1:[0-9]{1,5}` + path + `: 500 Internal Server Error$`))) + path = EndpointShowWebsite }) - Context("returned by /show_website.php", func() { - BeforeEach(func() { - path = EndpointShowWebsite - }) - Describe("Add()", func() { - It("returns error including HTTP status code", func() { - err = client.Add(context.Background(), acct) - }) - }) - Describe("Update()", func() { - It("returns error including HTTP status code", func() { - err = client.Update(context.Background(), acct) - }) + Describe("Add()", func() { + It("returns error including HTTP status code", func() { + err = client.Add(context.Background(), acct) }) - Describe("Delete()", func() { - It("returns error including HTTP status code", func() { - err = client.Delete(context.Background(), &Account{ID: "fakeID"}) - }) + }) + Describe("Update()", func() { + It("returns error including HTTP status code", func() { + err = client.Update(context.Background(), acct) }) }) - Describe("Logout()", func() { + Describe("Delete()", func() { It("returns error including HTTP status code", func() { - err = client.Logout(context.Background()) - path = EndpointLogout + err = client.Delete(context.Background(), &Account{ID: "fakeID"}) }) }) }) - When("Client Logout()", func() { - BeforeEach(func() { - form = url.Values{} - form.Set("method", "cli") - form.Set("noredirect", "1") - - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointLogout), - contentTypeVerifier, - ghttp.VerifyForm(form), - ghttp.RespondWith(http.StatusOK, ""), - ), - ) - Expect(client.Logout(context.Background())).To(Succeed()) - }) - AfterEach(func() { - // /iterations.php, /login.php, /login_check.php, /logout.php - Expect(server.ReceivedRequests()).To(HaveLen(4)) + Describe("Logout()", func() { + It("returns error including HTTP status code", func() { + err = client.Logout(context.Background()) + path = EndpointLogout }) - AssertUnauthenticatedBehavior() }) }) - When("session becomes dead (e.g. when session cookie expires)", func() { - rsp := ` - - - ` + When("Client Logout()", func() { BeforeEach(func() { + form = url.Values{} + form.Set("method", "cli") + form.Set("noredirect", "1") + server.AppendHandlers( ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointLoginCheck), - ghttp.RespondWith(http.StatusOK, rsp), + ghttp.VerifyRequest(http.MethodPost, EndpointLogout), + contentTypeVerifier, + ghttp.VerifyForm(form), + ghttp.RespondWith(http.StatusOK, ""), ), ) + Expect(client.Logout(context.Background())).To(Succeed()) }) AfterEach(func() { - // /iterations.php, /login.php, /login_check.php + // /login.php, /login_check.php, /logout.php Expect(server.ReceivedRequests()).To(HaveLen(3)) }) AssertUnauthenticatedBehavior() }) }) + When("session becomes dead (e.g. when session cookie expires)", func() { + rsp := ` + + + ` + BeforeEach(func() { + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, EndpointLoginCheck), + ghttp.RespondWith(http.StatusOK, rsp), + ), + ) + }) + AfterEach(func() { + // /login.php, /login_check.php + Expect(server.ReceivedRequests()).To(HaveLen(2)) + }) + AssertUnauthenticatedBehavior() + }) }) }) }) diff --git a/log_test.go b/log_test.go index c68360a..e15b874 100644 --- a/log_test.go +++ b/log_test.go @@ -27,12 +27,6 @@ var _ = Describe("Log", func() { passwd = readFile("passwd.txt") server = ghttp.NewServer() - server.AppendHandlers( - ghttp.CombineHandlers( - ghttp.VerifyRequest(http.MethodPost, EndpointIterations), - ghttp.RespondWith(http.StatusOK, "100100"), - ), - ) server.AppendHandlers( ghttp.CombineHandlers( ghttp.VerifyRequest(http.MethodPost, EndpointLogin), @@ -45,9 +39,7 @@ var _ = Describe("Log", func() { AfterEach(func() { Expect(err).NotTo(HaveOccurred()) - lines := strings.Split(logs.String(), "\n") - Expect(lines[0]).To(MatchRegexp(`^POST http://127\.0\.0\.1:[0-9]{1,5}/iterations\.php$`)) - Expect(lines[1]).To(MatchRegexp(`^POST http://127\.0\.0\.1:[0-9]{1,5}/login\.php$`)) + Expect(logs.String()).To(MatchRegexp(`^POST http://127\.0\.0\.1:[0-9]{1,5}/login\.php`)) server.Close() }) diff --git a/session.go b/session.go index 6625d00..08a74bc 100644 --- a/session.go +++ b/session.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/xml" "fmt" - "io/ioutil" "net/url" "strconv" "time" @@ -19,48 +18,26 @@ import ( // if the login fails with cause "outofbandrequired". // This increases the user's time to approve the out-of-band (2nd) factor // (e.g. approving a push notification sent to their mobile phone). -const MaxLoginRetries = 7 +const ( + MaxLoginRetries = 7 + defaultPasswdIterations = 100100 +) type session struct { + // passwdIterations controls how many times password is hashed using PBKDF2 + // before being sent to LastPass servers. passwdIterations int token string // user's private key for decrypting sharing keys (encryption keys of shared folders) privateKey *rsa.PrivateKey } -func (c *Client) initSession(ctx context.Context, password string) error { - c.session = &session{} - if err := c.requestIterationCount(ctx, c.user); err != nil { - return err - } - if err := c.login(ctx, password); err != nil { - return err - } - return nil -} - -func (c *Client) requestIterationCount(ctx context.Context, username string) error { - res, err := c.postForm(ctx, EndpointIterations, url.Values{"email": []string{username}}) - if err != nil { - return err - } - - defer res.Body.Close() - b, err := ioutil.ReadAll(res.Body) - if err != nil { - return err - } - - count, err := strconv.Atoi(string(b)) - if err != nil { - return err - } - c.session.passwdIterations = count - - return nil -} - func (c *Client) login(ctx context.Context, password string) error { + if c.session == nil { + c.session = &session{ + passwdIterations: defaultPasswdIterations, + } + } form := url.Values{ "method": []string{"cli"}, "xml": []string{"1"}, @@ -87,9 +64,10 @@ func (c *Client) login(ctx context.Context, password string) error { defer httpRsp.Body.Close() type Error struct { - Msg string `xml:"message,attr"` - Cause string `xml:"cause,attr"` - RetryID string `xml:"retryid,attr"` + Msg string `xml:"message,attr"` + Cause string `xml:"cause,attr"` + RetryID string `xml:"retryid,attr"` + Iterations string `xml:"iterations,attr"` } type response struct { Error Error `xml:"error"` @@ -101,6 +79,19 @@ func (c *Client) login(ctx context.Context, password string) error { return err } + if rsp.Error.Iterations != "" { + var iterations int + if iterations, err = strconv.Atoi(rsp.Error.Iterations); err != nil { + return fmt.Errorf( + "failed to parse iterations count, expected '%s' to be integer: %w", + rsp.Error.Iterations, err) + } + c.log(ctx, "failed to login with %d password iterations, re-trying with %d password iterations...", + c.session.passwdIterations, iterations) + c.session.passwdIterations = iterations + return c.login(ctx, password) + } + const outOfBandRequired = "outofbandrequired" if rsp.Error.Cause == outOfBandRequired { form.Set("outofbandrequest", "1")