diff --git a/CHANGELOG.md b/CHANGELOG.md index 3138cd4..2e798b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] MM-DD-YYYY +## [1.2.0] 10-14-2017 +### Added +- Mutlifactor Authentication + ## [1.1.0] 10-11-2017 ### Changed - Many internal functions now return errors instead of panicking @@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). [1.0.0]: https://github.com/while-loop/lastpass-go/releases/tag/v1.0.0 [1.1.0]: https://github.com/while-loop/lastpass-go/compare/v1.0.0...v1.1.0 -[Unreleased]: https://github.com/while-loop/lastpass-go/compare/v1.1.0...master +[1.2.0]: https://github.com/while-loop/lastpass-go/compare/v1.1.0...v1.2.0 +[Unreleased]: https://github.com/while-loop/lastpass-go/compare/v1.2.0...master [comment]: # (Added, Changed, Removed) diff --git a/README.md b/README.md index 118e95f..ae305f6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Features - Create/Update accounts - Delete accounts - Get accounts +- Multi-factor authentication Installation ------------ @@ -35,8 +36,20 @@ $ go get github.com/while-loop/lastpass-go Usage ----- +[Example Usages](vault_test.go) + +```go +lp, _ := lastpass.New(email, password) +accs, _ := lp.GetAccounts() +for _, account := range accs { + fmt.Println(account.Username, account.Password) +} +``` + +#### With Multi-factor Auth + ```go -lp, _ := lastpass.New(username, password) +lp, err := New(email, password, WithMultiFactor("5412548")) accs, _ := lp.GetAccounts() for _, account := range accs { fmt.Println(account.Username, account.Password) @@ -49,7 +62,6 @@ TODO These are future plans for the project, feel free fork/pr these features if I don't get to them in time. -- 2FA login - Shared groups - Secured notes diff --git a/account_test.go b/account_test.go new file mode 100644 index 0000000..0f5627a --- /dev/null +++ b/account_test.go @@ -0,0 +1,18 @@ +package lastpass + +import ( + "testing" + "github.com/stretchr/testify/assert" +) + +func TestStringFormat(t *testing.T) { + assert.Contains(t, Account{Username: "yoyo@gmail.com"}.String(), "yoyo@gmail.com") +} + +func TestFailEncInvalidKeySize(t *testing.T) { + acc := Account{Username: "yoyo@gmail.com"} + vals, err := acc.encrypt(s2b("sd")) + assert.Nil(t, vals) + assert.Contains(t, err.Error(), "invalid key size 2") + assert.Contains(t, err.Error(), "username") +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..86f7a0b --- /dev/null +++ b/config.go @@ -0,0 +1,19 @@ +package lastpass + +// ConfigFunc allows modification of configurations +// in the Vault struct +type ConfigFunc func(opts *ConfigOptions) + +// ConfigOptions are config options that +// set behaviours in Vault. +// Current supported configs is multi-factor auth. +type ConfigOptions struct { + multiFactor string +} + +// WithMultiFactor adds multi-factor auth to your vault. +func WithMultiFactor(code string) ConfigFunc { + return func(opts *ConfigOptions) { + opts.multiFactor = code + } +} diff --git a/example/credentials.txt.example b/example/credentials.txt.example deleted file mode 100644 index 41cd875..0000000 --- a/example/credentials.txt.example +++ /dev/null @@ -1,2 +0,0 @@ -username@example.com -password diff --git a/example/example.go b/example/example.go deleted file mode 100644 index f873e9b..0000000 --- a/example/example.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "github.com/while-loop/lastpass-go" - "io/ioutil" - "log" - "os" - "strings" -) - -func main() { - b, err := ioutil.ReadFile("example/credentials.txt") - if err != nil { - log.Fatal(err) - } - lines := strings.Split(string(b), "\n") - username := lines[0] - password := lines[1] - - lp, err := lastpass.New(username, password) - if err != nil { - log.Fatal(err) - } - - accs, err := lp.GetAccounts() - if err != nil { - log.Fatal(err) - } - - for _, account := range accs { - json.NewEncoder(os.Stdout).Encode(account) - fmt.Println() - } -} diff --git a/fetcher.go b/fetcher.go index f7f4741..5316526 100644 --- a/fetcher.go +++ b/fetcher.go @@ -13,6 +13,8 @@ import ( "net/http/cookiejar" "net/url" "strconv" + "github.com/pkg/errors" + "bytes" ) type blob struct { @@ -28,57 +30,93 @@ type session struct { key []byte } -func buildLastPassURL(path string) *url.URL { - return &url.URL{ - Scheme: "https", - Host: "lastpass.com", - Path: path, - } -} +const ( + loginPage = "login.php" + iterationsPage = "iterations.php" + getAccountsPage = "getaccts.php" + +) var ( - ErrInvalidPassword = fmt.Errorf("invalid username or password") + ErrInvalidPassword = fmt.Errorf("invalid password") + ErrInvalidEmail = fmt.Errorf("invalid username or password") + ErrInvalidGoogleAuthCode = fmt.Errorf("googleauthfailed") + ErrInvalidYubiKey = fmt.Errorf("yubikeyrestricted") ) -func login(username, password string) (*session, error) { +func login(username, password string, multiFactor string) (*session, error) { iterationCount, err := requestIterationCount(username) if err != nil { return nil, err } - return make_session(username, password, iterationCount) + return make_session(username, password, iterationCount, multiFactor) } -func make_session(username, password string, iterationCount int) (*session, error) { +func make_session(username, password string, iterationCount int, multiFactor string) (*session, error) { cookieJar, err := cookiejar.New(nil) if err != nil { return nil, err } - client := &http.Client{ - Jar: cookieJar, + client := newClient(cookieJar) + + vals := url.Values{ + "method": []string{"mobile"}, + "web": []string{"1"}, + "xml": []string{"1"}, + "username": []string{username}, + "hash": []string{string(makeHash(username, password, iterationCount))}, + "iterations": []string{fmt.Sprint(iterationCount)}, + } + if multiFactor != "" { + vals.Set("otp", multiFactor) } - res, err := client.PostForm( - buildLastPassURL("login.php").String(), - url.Values{ - "method": []string{"mobile"}, - "web": []string{"1"}, - "xml": []string{"1"}, - "username": []string{username}, - "hash": []string{string(makeHash(username, password, iterationCount))}, - "iterations": []string{fmt.Sprint(iterationCount)}, - }) + + res, err := client.PostForm(buildLastPassURL(loginPage).String(), vals) if err != nil { - return nil, err + return nil, errors.Wrap(err, "unable to reach LastPass servers") } + defer res.Body.Close() var response struct { SessionId string `xml:"sessionid,attr"` Token string `xml:"token,attr"` + ErrResp *struct { + AttrAllowmultifactortrust string `xml:" allowmultifactortrust,attr" json:",omitempty"` + AttrCause string `xml:" cause,attr" json:",omitempty"` + AttrHidedisable string `xml:" hidedisable,attr" json:",omitempty"` + AttrMessage string `xml:" message,attr" json:",omitempty"` + AttrTempuid string `xml:" tempuid,attr" json:",omitempty"` + AttrTrustexpired string `xml:" trustexpired,attr" json:",omitempty"` + AttrTrustlabel string `xml:" trustlabel,attr" json:",omitempty"` + } `xml:" error,omitempty" json:"error,omitempty"` + } + + // read to bytes for debugging + b, err := ioutil.ReadAll(res.Body) + if err != nil && err != io.EOF { + return nil, err } - err = xml.NewDecoder(res.Body).Decode(&response) + + err = xml.NewDecoder(bytes.NewReader(b)).Decode(&response) if err != nil { return nil, err } + if response.ErrResp != nil { + switch response.ErrResp.AttrCause { + case "googleauthfailed", "googleauthrequired": + return nil, ErrInvalidGoogleAuthCode + case "unknownpassword": + return nil, ErrInvalidPassword + case "yubikeyrestricted": + return nil, ErrInvalidYubiKey + case "unknownemail": + return nil, ErrInvalidEmail + default: + return nil, fmt.Errorf("%s", response.ErrResp.AttrMessage) + } + } + key := makeKey(username, password, iterationCount) return &session{response.SessionId, response.Token, @@ -89,30 +127,32 @@ func make_session(username, password string, iterationCount int) (*session, erro } func fetch(s *session) (*blob, error) { - u := buildLastPassURL("getaccts.php") + u := buildLastPassURL(getAccountsPage) u.RawQuery = (&url.Values{ "mobile": []string{"1"}, "b64": []string{"1"}, "hash": []string{"0.0"}, "PHPSESSID": []string{s.id}, }).Encode() - client := &http.Client{ - Jar: s.cookieJar, - } + + client := newClient(s.cookieJar) + res, err := client.Get(u.String()) if err != nil { return nil, err } defer res.Body.Close() - if res.StatusCode == http.StatusForbidden { - return nil, ErrInvalidPassword - } - b, err := ioutil.ReadAll(res.Body) if err != nil && err != io.EOF { return nil, err } + + //fmt.Println(string(b)) + if res.StatusCode == http.StatusForbidden { + return nil, ErrInvalidPassword + } + b, err = base64.StdEncoding.DecodeString(string(b)) if err != nil { return nil, err @@ -126,11 +166,7 @@ func post(postUrl *url.URL, s *session, values *url.Values) (string, error) { } values.Set("token", string(s.token)) - // TODO fix encoding b64 - // 2017/10/09 11:46:30 - client := &http.Client{ - Jar: s.cookieJar, - } + client := newClient(s.cookieJar) res, err := client.PostForm(postUrl.String(), *values) if err != nil { @@ -150,19 +186,9 @@ func post(postUrl *url.URL, s *session, values *url.Values) (string, error) { return string(b), nil } -func encodeValues(values *url.Values) *url.Values { - newValues := &url.Values{} - for key, val := range *values { - for _, v := range val { - newValues.Add(key, base64.StdEncoding.EncodeToString(s2b(v))) - } - } - return newValues -} - func requestIterationCount(username string) (int, error) { res, err := http.DefaultClient.PostForm( - buildLastPassURL("iterations.php").String(), + buildLastPassURL(iterationsPage).String(), url.Values{ "email": []string{username}, }) @@ -197,3 +223,18 @@ func makeHash(username, password string, iterationCount int) []byte { } return lcrypt.EncodeHex(pbkdf2.Key([]byte(key), []byte(password), 1, 32, sha256.New)) } + +// used to mock lastpass responses +var newClient = func(jar http.CookieJar) *http.Client { + return &http.Client{ + Jar: jar, + } +} + +func buildLastPassURL(path string) *url.URL { + return &url.URL{ + Scheme: "https", + Host: "lastpass.com", + Path: path, + } +} \ No newline at end of file diff --git a/fetcher_test.go b/fetcher_test.go new file mode 100644 index 0000000..693446d --- /dev/null +++ b/fetcher_test.go @@ -0,0 +1,19 @@ +package lastpass + +import ( + "testing" + "github.com/while-loop/lastpass-go/internal/crypt" + "github.com/stretchr/testify/assert" +) + +func TestIterationCount1(t *testing.T) { + expected := []byte("65333739643937326333656235393537396162653338363464383530623566353439313135343461646661326461663966623533633035643330636463393835") + hash := crypt.EncodeHex(makeHash("username", "password", 1)) + assert.Equal(t, expected, hash) +} + +func TestIterationCount2(t *testing.T) { + expected := []byte("38363361663762326636373131386162643139623936313265343233313661363033666664646437666330623730353334313936356331653839643864333565") + hash := crypt.EncodeHex(makeHash("username", "password", 2)) + assert.Equal(t, expected, hash) +} diff --git a/internal/crypt/aes.go b/internal/crypt/aes.go index c04ce64..68fd358 100644 --- a/internal/crypt/aes.go +++ b/internal/crypt/aes.go @@ -84,7 +84,7 @@ func encrypt_aes256_cbc(plaintext, iv, key []byte) ([]byte, error) { func Encrypt_aes256_cbc_base64(plaintext, key []byte) ([]byte, error) { iv, _, err := getIv() if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to get iv to encode aes256") } ctext, err := encrypt_aes256_cbc_base64(plaintext, iv, key) diff --git a/internal/crypt/encoding_test.go b/internal/crypt/encoding_test.go index 22576bf..9275eab 100644 --- a/internal/crypt/encoding_test.go +++ b/internal/crypt/encoding_test.go @@ -52,3 +52,15 @@ func TestDecodeBase64(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "gg no re too ez", string(decoded)) } + +func TestDecodeBase64WithInvalidChars(t *testing.T) { + decoded, err := DecodeBase64([]byte("-z*/asdag")) + assert.Nil(t, decoded) + assert.Error(t, err) +} + +func TestDecodeHexWithInvalidChars(t *testing.T) { + decoded, err := DecodeHex([]byte("-z*/asdag")) + assert.Nil(t, decoded) + assert.Error(t, err) +} diff --git a/vault.go b/vault.go index 46eeaf6..130a547 100644 --- a/vault.go +++ b/vault.go @@ -17,11 +17,17 @@ var ( type Vault struct { sesh *session email string + opts *ConfigOptions } // New logs into LastPass and returns a new Vault -func New(email, password string) (*Vault, error) { - session, err := login(email, password) +func New(email, password string, opts ...ConfigFunc) (*Vault, error) { + configOpts := new(ConfigOptions) + for _, opt := range opts { + opt(configOpts) + } + + session, err := login(email, password, configOpts.multiFactor) if err != nil { return nil, err } @@ -33,7 +39,7 @@ func New(email, password string) (*Vault, error) { return nil, err } - return &Vault{sesh: session, email: email}, nil + return &Vault{sesh: session, email: email, opts: configOpts}, nil } // Email returns the email associated with the vault diff --git a/vault_test.go b/vault_test.go index 5b52ccb..f5a6280 100644 --- a/vault_test.go +++ b/vault_test.go @@ -4,6 +4,7 @@ import ( "github.com/stretchr/testify/assert" "os" "testing" + "gopkg.in/jarcoal/httpmock.v1" ) const ( @@ -30,7 +31,13 @@ var config = struct { func TestInvalidEmail(t *testing.T) { lp, err := New("fakeemail@hotmail.com", "fakepassword") assert.Nil(t, lp) - assert.Equal(t, ErrInvalidPassword, err) + assert.EqualError(t, err, ErrInvalidEmail.Error()) +} + +func TestInvalidPassword(t *testing.T) { + lp, err := New(config.email, "fakepassword") + assert.Nil(t, lp) + assert.EqualError(t, err, ErrInvalidPassword.Error()) } func TestCRUD(t *testing.T) { @@ -71,6 +78,51 @@ func TestCRUD(t *testing.T) { assert.Empty(t, actuals) } +func TestIncorrectGoogleAuthCode(t *testing.T) { + if os.Getenv("MOCK_LP") != "" { + t.Logf("running %s in mock mode", t.Name()) + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + data := `` + httpmock.RegisterResponder("POST", buildLastPassURL(iterationsPage).String(), httpmock.NewStringResponder(200, "5461")) + httpmock.RegisterResponder("POST", buildLastPassURL(loginPage).String(), httpmock.NewStringResponder(200, data)) + } + + lp, err := New("zqg45101@loaoa.com", "qwerty123", WithMultiFactor("-3")) + assert.Nil(t, lp) + assert.EqualError(t, err, ErrInvalidGoogleAuthCode.Error()) +} + +func TestNoGoogleAuthCodeGiven(t *testing.T) { + if os.Getenv("MOCK_LP") != "" { + t.Logf("running %s in mock mode", t.Name()) + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + data := `` + httpmock.RegisterResponder("POST", buildLastPassURL(iterationsPage).String(), httpmock.NewStringResponder(200, "5461")) + httpmock.RegisterResponder("POST", buildLastPassURL(loginPage).String(), httpmock.NewStringResponder(200, data)) + } + + lp, err := New("ocr94395@loaoa.com", "qwerty123") + assert.Nil(t, lp) + assert.EqualError(t, err, ErrInvalidGoogleAuthCode.Error()) +} +func TestInvalidYubiKey(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Logf("running %s in mock mode", t.Name()) + + data := `` + httpmock.RegisterResponder("POST", buildLastPassURL(iterationsPage).String(), httpmock.NewStringResponder(200, "5461")) + httpmock.RegisterResponder("POST", buildLastPassURL(loginPage).String(), httpmock.NewStringResponder(200, data)) + + lp, err := New("zqg4s5101@loaoa.com", "qwerty123") + assert.Nil(t, lp) + assert.EqualError(t, err, ErrInvalidYubiKey.Error()) +} + func mustDeleteAccounts(lp *Vault) { accs, err := lp.GetAccounts() if err != nil {