Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Features
- Create/Update accounts
- Delete accounts
- Get accounts
- Multi-factor authentication

Installation
------------
Expand All @@ -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)
Expand All @@ -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

Expand Down
18 changes: 18 additions & 0 deletions account_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
19 changes: 19 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 0 additions & 2 deletions example/credentials.txt.example

This file was deleted.

36 changes: 0 additions & 36 deletions example/example.go

This file was deleted.

139 changes: 90 additions & 49 deletions fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"net/http/cookiejar"
"net/url"
"strconv"
"github.com/pkg/errors"
"bytes"
)

type blob struct {
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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 <xmlresponse><result action="added" aid="2215972459054203220" urid="0" msg="accountadded" acctname1="" acctname2="" acctname3="" acctname4="" acctname5="" acctname6="" grouping="!dBYwP0uxf3HfGMqnRoWcMQ==|SWtDmymO8K7rB8wJWAUVoQ==" count="0" lasttouch="0000-00-00 00:00:00" editlink="" url="687474703a2f2f66616365626f6f6b2e636f6d" fav="0" launchjs="" deleted="0" remoteshare="0" username="IbMaWDJ4UpzVaOACQYsaVZ8B3U4TsvwBmwUKg1Ok0Q6eAAAAAAAAAA==" localupdate="1" accts_version="36" pwprotect="0" submit_id="" captcha_id="" custom_js="" ></result></xmlresponse>
client := &http.Client{
Jar: s.cookieJar,
}
client := newClient(s.cookieJar)

res, err := client.PostForm(postUrl.String(), *values)
if err != nil {
Expand All @@ -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},
})
Expand Down Expand Up @@ -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,
}
}
19 changes: 19 additions & 0 deletions fetcher_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 1 addition & 1 deletion internal/crypt/aes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading