Skip to content

Commit 70e940a

Browse files
author
Terry Hardie
committed
Added support for LastPass Authenticator and trusting endpoint
1 parent 5063911 commit 70e940a

5 files changed

Lines changed: 171 additions & 32 deletions

File tree

lastpass/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ class LastPassIncorrectYubikeyPasswordError(Error):
4949
pass
5050

5151

52+
class LastPassIncorrectOutOfBandRequiredError(Error):
53+
"""LastPass error: need to provide out of band authentication (e.g, LastPass Authenticator)"""
54+
pass
55+
56+
57+
class LastPassIncorrectMultiFactorResponseError(Error):
58+
"""LastPass error: Multifactor response failed (wrong code or denied)"""
59+
pass
60+
61+
5262
class LastPassUnknownError(Error):
5363
"""LastPass error we don't know about"""
5464
pass

lastpass/fetcher.py

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# coding: utf-8
22
import hashlib
3+
import random
4+
import string
35
from base64 import b64decode
46
from binascii import hexlify
57
import requests
@@ -13,6 +15,8 @@
1315
LastPassInvalidPasswordError,
1416
LastPassIncorrectGoogleAuthenticatorCodeError,
1517
LastPassIncorrectYubikeyPasswordError,
18+
LastPassIncorrectOutOfBandRequiredError,
19+
LastPassIncorrectMultiFactorResponseError,
1620
LastPassUnknownError
1721
)
1822
from .session import Session
@@ -21,9 +25,9 @@
2125
http = requests
2226

2327

24-
def login(username, password, multifactor_password=None, client_id=None):
28+
def login(username, password, multifactor_password=None, client_id=None, trust_id=None, trust_me=False):
2529
key_iteration_count = request_iteration_count(username)
26-
return request_login(username, password, key_iteration_count, multifactor_password, client_id)
30+
return request_login(username, password, key_iteration_count, multifactor_password, client_id, trust_id=trust_id, trust_me=trust_me)
2731

2832

2933
def logout(session, web_client=http):
@@ -63,21 +67,31 @@ def request_iteration_count(username, web_client=http):
6367
raise InvalidResponseError('Key iteration count is not positive')
6468

6569

66-
def request_login(username, password, key_iteration_count, multifactor_password=None, client_id=None, web_client=http):
70+
def request_login(username, password, key_iteration_count, multifactor_password=None, client_id=None, web_client=http, trust_id=None, trust_me=False):
6771
body = {
68-
'method': 'mobile',
69-
'web': 1,
70-
'xml': 1,
72+
'method': 'cli',
73+
'xml': 2,
7174
'username': username,
7275
'hash': make_hash(username, password, key_iteration_count),
7376
'iterations': key_iteration_count,
77+
'includeprivatekeyenc': 1,
78+
'outofbandsupported': 1
7479
}
7580

7681
if multifactor_password:
7782
body['otp'] = multifactor_password
7883

84+
# if client_id:
85+
# body['imei'] = client_id
86+
87+
if trust_me and not trust_id:
88+
trust_id = generate_trust_id()
89+
90+
if trust_id:
91+
body['uuid'] = trust_id
92+
7993
if client_id:
80-
body['imei'] = client_id
94+
body['trustlabel'] = client_id
8195

8296
response = web_client.post('https://lastpass.com/login.php',
8397
data=body)
@@ -93,17 +107,83 @@ def request_login(username, password, key_iteration_count, multifactor_password=
93107
if parsed_response is None:
94108
raise InvalidResponseError()
95109

96-
session = create_session(parsed_response, key_iteration_count)
110+
session = create_session(parsed_response, key_iteration_count, trust_id)
97111
if not session:
98-
raise login_error(parsed_response)
112+
try:
113+
raise login_error(parsed_response)
114+
except LastPassIncorrectOutOfBandRequiredError:
115+
(session, parsed_response) = oob_login(web_client, parsed_response,
116+
body, key_iteration_count,
117+
trust_id)
118+
if not session:
119+
raise login_error(parsed_response)
120+
if trust_me:
121+
response = web_client.post('https://lastpass.com/trust.php',
122+
cookies={'PHPSESSID': session.id},
123+
data={
124+
"token": session.token,
125+
"uuid": trust_id,
126+
"trustlabel": client_id,
127+
})
128+
99129
return session
100130

101131

102-
def create_session(parsed_response, key_iteration_count):
132+
def oob_login(web_client, parsed_response, body, key_iteration_count, trust_id):
133+
error = None if parsed_response.tag != 'response' else parsed_response.find(
134+
'error')
135+
if 'outofbandname' not in error.attrib or 'capabilities' not in error.attrib:
136+
return (None, parsed_response)
137+
oob_name = error.attrib['outofbandname']
138+
oob_capabilities = error.attrib['capabilities'].split(',')
139+
can_do_passcode = 'passcode' in oob_capabilities
140+
if can_do_passcode and 'outofband' not in oob_capabilities:
141+
return (None, parsed_response)
142+
body['outofbandrequest'] = '1'
143+
retries = 0
144+
# loop waiting for out of band approval, or failure
145+
while retries < 5:
146+
retries += 1
147+
response = web_client.post("https://lastpass.com/login.php", data=body)
148+
if response.status_code != requests.codes.ok:
149+
raise NetworkError()
150+
151+
try:
152+
parsed_response = etree.fromstring(response.content)
153+
except etree.ParseError:
154+
parsed_response = None
155+
156+
if parsed_response is None:
157+
raise InvalidResponseError()
158+
159+
session = create_session(parsed_response, key_iteration_count, trust_id)
160+
if session:
161+
return (session, parsed_response)
162+
error = None if parsed_response.tag != 'response' else parsed_response.find(
163+
'error')
164+
if 'cause' in error.attrib and error.attrib['cause'] == 'outofbandrequired':
165+
if 'retryid' in error.attrib:
166+
body['outofbandretryid'] = error.attrib['retryid']
167+
body['outofbandretry'] = "1"
168+
continue
169+
return (None, parsed_response)
170+
return (None, parsed_response)
171+
172+
173+
def generate_trust_id():
174+
return ''.join(random.choice(string.ascii_uppercase + string.digits + string.ascii_lowercase + "!@#$") for _ in range(32))
175+
176+
177+
def create_session(parsed_response, key_iteration_count, trust_id):
103178
if parsed_response.tag == 'ok':
104-
session_id = parsed_response.attrib.get('sessionid')
179+
ok_response = parsed_response
180+
else:
181+
ok_response = parsed_response.find("ok")
182+
if ok_response is not None:
183+
session_id = ok_response.attrib.get('sessionid')
184+
token = ok_response.attrib.get('token')
105185
if isinstance(session_id, str):
106-
return Session(session_id, key_iteration_count)
186+
return Session(session_id, key_iteration_count, token, trust_id)
107187

108188

109189
def login_error(parsed_response):
@@ -117,6 +197,8 @@ def login_error(parsed_response):
117197
"googleauthrequired": LastPassIncorrectGoogleAuthenticatorCodeError,
118198
"googleauthfailed": LastPassIncorrectGoogleAuthenticatorCodeError,
119199
"yubikeyrestricted": LastPassIncorrectYubikeyPasswordError,
200+
"outofbandrequired": LastPassIncorrectOutOfBandRequiredError,
201+
"multifactorresponsefailed": LastPassIncorrectMultiFactorResponseError,
120202
}
121203

122204
cause = error.attrib.get('cause')

lastpass/session.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# coding: utf-8
22
class Session(object):
3-
def __init__(self, id, key_iteration_count):
3+
def __init__(self, id, key_iteration_count, token=None, trust_id=None):
44
self.id = id
55
self.key_iteration_count = key_iteration_count
6+
self.token = token
7+
self.trust_id = trust_id
68

79
def __eq__(self, other):
8-
return self.id == other.id and self.key_iteration_count == other.key_iteration_count
10+
return self.id == other.id and self.key_iteration_count == other.key_iteration_count and self.token == other.token

lastpass/vault.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
class Vault(object):
88
@classmethod
9-
def open_remote(cls, username, password, multifactor_password=None, client_id=None):
9+
def open_remote(cls, username, password, multifactor_password=None, client_id=None, trust_id=None, trust_me=False):
1010
"""Fetches a blob from the server and creates a vault"""
11-
blob = cls.fetch_blob(username, password, multifactor_password, client_id)
12-
return cls.open(blob, username, password)
11+
(blob, trust_id) = cls.fetch_blob(username, password, multifactor_password, client_id, trust_id, trust_me)
12+
return cls.open(blob, username, password, trust_id)
1313

1414
@classmethod
1515
def open_local(cls, blob_filename, username, password):
@@ -18,27 +18,28 @@ def open_local(cls, blob_filename, username, password):
1818
raise NotImplementedError()
1919

2020
@classmethod
21-
def open(cls, blob, username, password):
21+
def open(cls, blob, username, password, trust_id=None):
2222
"""Creates a vault from a blob object"""
23-
return cls(blob, blob.encryption_key(username, password))
23+
return cls(blob, blob.encryption_key(username, password), trust_id)
2424

2525
@classmethod
26-
def fetch_blob(cls, username, password, multifactor_password=None, client_id=None):
26+
def fetch_blob(cls, username, password, multifactor_password=None, client_id=None, trust_id=None, trust_me=False):
2727
"""Just fetches the blob, could be used to store it locally"""
28-
session = fetcher.login(username, password, multifactor_password, client_id)
28+
session = fetcher.login(username, password, multifactor_password, client_id, trust_id=trust_id, trust_me=trust_me)
2929
blob = fetcher.fetch(session)
3030
fetcher.logout(session)
3131

32-
return blob
32+
return (blob, session.trust_id)
3333

34-
def __init__(self, blob, encryption_key):
34+
def __init__(self, blob, encryption_key, trust_id=None):
3535
"""This more of an internal method, use one of the static constructors instead"""
3636
chunks = parser.extract_chunks(blob)
3737

3838
if not self.is_complete(chunks):
3939
raise InvalidResponseError('Blob is truncated')
4040

4141
self.accounts = self.parse_accounts(chunks, encryption_key)
42+
self.trust_id = trust_id
4243

4344
def is_complete(self, chunks):
4445
return len(chunks) > 0 and chunks[-1].id == b'ENDM' and chunks[-1].payload == b'OK'

tests/test_fetcher.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,37 @@ def setUp(self):
1616

1717
self.hash = b'7880a04588cfab954aa1a2da98fd9c0d2c6eba4c53e36a94510e6dbf30759256'
1818
self.session_id = '53ru,Hb713QnEVM5zWZ16jMvxS0'
19-
self.session = Session(self.session_id, self.key_iteration_count)
19+
self.token = '54aa1a2da98fd9c0d2c6eba4c5'
20+
self.session = Session(self.session_id, self.key_iteration_count, token=self.token)
2021

2122
self.blob_response = 'TFBBVgAAAAMxMjJQUkVNAAAACjE0MTQ5'
2223
self.blob_bytes = b64decode(self.blob_response)
2324
self.blob = Blob(self.blob_bytes, self.key_iteration_count)
2425

25-
self.login_post_data = {'method': 'mobile',
26-
'web': 1,
27-
'xml': 1,
26+
self.login_post_data = {'method': 'cli',
27+
'xml': 2,
28+
'outofbandsupported': 1,
29+
'includeprivatekeyenc': 1,
2830
'username': self.username,
2931
'hash': self.hash,
3032
'iterations': self.key_iteration_count}
3133

3234
self.device_id = '492378378052455'
3335
self.login_post_data_with_device_id = self.login_post_data.copy()
34-
self.login_post_data_with_device_id.update({'imei': self.device_id})
36+
self.login_post_data_with_device_id.update({'trustlabel': self.device_id})
37+
self.login_post_data_with_device_id.update({'uuid': self.device_id})
38+
39+
self.trust_id = '@2ykJ0Tp#dVi06qh6g6kvzOqjQGAWfKv'
40+
41+
self.request_trust_data = {
42+
'token': self.token,
43+
'trustlabel': self.device_id,
44+
'uuid': self.trust_id
45+
}
46+
47+
self.request_trust_cookies = {
48+
'PHPSESSID': self.session_id
49+
}
3550

3651
self.google_authenticator_code = '12345'
3752
self.yubikey_password = 'emdbwzemyisymdnevznyqhqnklaqheaxszzvtnxjrmkb'
@@ -93,6 +108,9 @@ def test_request_login_makes_a_post_request(self):
93108
def test_request_login_makes_a_post_request_with_device_id(self):
94109
self._verify_request_login_post_request(None, self.device_id, self.login_post_data_with_device_id)
95110

111+
def test_request_login_makes_a_post_request_with_trust_requested(self):
112+
self._verify_request_trust(None, self.device_id, self.request_trust_data, cookies=self.request_trust_cookies, trust_id=self.trust_id, trust_me=True)
113+
96114
def test_request_login_makes_a_post_request_with_google_authenticator_code(self):
97115
self._verify_request_login_post_request(self.google_authenticator_code,
98116
None,
@@ -104,7 +122,8 @@ def test_request_login_makes_a_post_request_with_yubikey_password(self):
104122
self.login_post_data_with_yubikey_password)
105123

106124
def test_request_login_returns_a_session(self):
107-
self.assertEqual(self._request_login_with_xml('<ok sessionid="{}" />'.format(self.session_id)), self.session)
125+
tested_session = self._request_login_with_xml('<ok sessionid="{}" token="{}"/>'.format(self.session_id, self.token))
126+
self.assertEqual(tested_session, self.session)
108127

109128
def test_request_login_raises_an_exception_on_http_error(self):
110129
self.assertRaises(lastpass.NetworkError, self._request_login_with_error)
@@ -147,6 +166,14 @@ def test_request_login_raises_an_exception_on_missing_or_incorrect_yubikey_passw
147166
self.assertRaises(lastpass.LastPassIncorrectYubikeyPasswordError,
148167
self._request_login_with_lastpass_error, 'yubikeyrestricted', message)
149168

169+
def test_request_login_raises_an_exception_on_lastpass_authenticator(self):
170+
message = 'Multifactor authentication required! ' \
171+
'Upgrade your browser extension so you can enter it.'
172+
self.assertRaises(lastpass.LastPassIncorrectOutOfBandRequiredError,
173+
self._request_login_with_lastpass_multifactor_required, 'outofbandrequired', message)
174+
self.assertRaises(lastpass.LastPassIncorrectMultiFactorResponseError,
175+
self._request_login_with_lastpass_multifactor_required, 'multifactorresponsefailed', message)
176+
150177
def test_request_login_raises_an_exception_on_unknown_lastpass_error_without_a_message(self):
151178
cause = 'Unknown cause'
152179
self.assertRaises(lastpass.LastPassUnknownError,
@@ -162,7 +189,8 @@ def test_fetch_makes_a_get_request(self):
162189
def test_fetch_returns_a_blob(self):
163190
m = mock.Mock()
164191
m.get.return_value = self._http_ok(self.blob_response)
165-
self.assertEqual(fetcher.fetch(self.session, m), self.blob)
192+
returned_blob = fetcher.fetch(self.session, m)
193+
self.assertEqual(returned_blob, self.blob)
166194

167195
def test_fetch_raises_exception_on_http_error(self):
168196
m = mock.Mock()
@@ -197,12 +225,18 @@ def test_make_hash(self):
197225
for iterations, hash in hashes:
198226
self.assertEqual(hash, fetcher.make_hash('postlass@gmail.com', 'pl1234567890', iterations))
199227

200-
def _verify_request_login_post_request(self, multifactor_password, device_id, post_data):
228+
def _verify_request_login_post_request(self, multifactor_password, device_id, post_data, trust_me=False):
201229
m = mock.Mock()
202230
m.post.return_value = self._http_ok('<ok sessionid="{}" />'.format(self.session_id))
203-
fetcher.request_login(self.username, self.password, self.key_iteration_count, multifactor_password, device_id, m)
231+
fetcher.request_login(self.username, self.password, self.key_iteration_count, multifactor_password, device_id, m, trust_id=device_id, trust_me=trust_me)
204232
m.post.assert_called_with('https://lastpass.com/login.php', data=post_data)
205233

234+
def _verify_request_trust(self, multifactor_password, device_id, post_data, cookies, trust_id, trust_me=False):
235+
m = mock.Mock()
236+
m.post.return_value = self._http_ok('<ok sessionid="{}" token="{}"/>'.format(self.session_id, self.token))
237+
fetcher.request_login(self.username, self.password, self.key_iteration_count, multifactor_password, device_id, m, trust_id=trust_id, trust_me=trust_me)
238+
m.post.assert_called_with('https://lastpass.com/trust.php', data=post_data, cookies=cookies)
239+
206240
@staticmethod
207241
def _mock_response(code, body):
208242
m = mock.Mock()
@@ -222,9 +256,19 @@ def _lastpass_error(cause, message):
222256
return '<response><error cause="{}" message="{}" /></response>'.format(cause, message)
223257
return '<response><error cause="{}" /></response>'.format(cause)
224258

259+
@staticmethod
260+
def _lastpass_multifactor_required(cause, message):
261+
if message:
262+
return '<response><error message="{}" cause="{}" allowtrust="1" capabilities="push,totp,sms,outofband,outofbandauto,passcode" outofbandtype="lastpassauth" outofbandname="LastPass Authenticator" allowmultifactortrust="true" trustexpired="0" trustlabel="" hidedisable="false" /></response>'.format(message, cause)
263+
return '<response><error cause="{}" allowtrust="1" capabilities="push,totp,sms,outofband,outofbandauto,passcode" outofbandtype="lastpassauth" outofbandname="LastPass Authenticator" allowmultifactortrust="true" trustexpired="0" trustlabel="" hidedisable="false" /></response>'.format(cause)
264+
225265
def _request_login_with_lastpass_error(self, cause, message=None):
226266
return self._request_login_with_xml(self._lastpass_error(cause, message))
227267

268+
def _request_login_with_lastpass_multifactor_required(self, cause, message=None):
269+
return self._request_login_with_xml(
270+
self._lastpass_multifactor_required(cause, message))
271+
228272
def _request_login_with_xml(self, text):
229273
return self._request_login_with_ok(text)
230274

0 commit comments

Comments
 (0)