Skip to content

Commit 403efc2

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

5 files changed

Lines changed: 160 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: 83 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,28 @@ 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 trust_me and not trust_id:
85+
trust_id = generate_trust_id()
86+
87+
if trust_id:
88+
body['uuid'] = trust_id
89+
7990
if client_id:
80-
body['imei'] = client_id
91+
body['trustlabel'] = client_id
8192

8293
response = web_client.post('https://lastpass.com/login.php',
8394
data=body)
@@ -93,17 +104,75 @@ def request_login(username, password, key_iteration_count, multifactor_password=
93104
if parsed_response is None:
94105
raise InvalidResponseError()
95106

96-
session = create_session(parsed_response, key_iteration_count)
107+
session = create_session(parsed_response, key_iteration_count, trust_id)
97108
if not session:
98-
raise login_error(parsed_response)
109+
try:
110+
raise login_error(parsed_response)
111+
except LastPassIncorrectOutOfBandRequiredError:
112+
(session, parsed_response) = oob_login(web_client, parsed_response, body, key_iteration_count, trust_id)
113+
if not session:
114+
raise login_error(parsed_response)
115+
if trust_me:
116+
response = web_client.post('https://lastpass.com/trust.php', cookies={'PHPSESSID': session.id}, data={"token": session.token, "uuid": trust_id, "trustlabel": client_id})
117+
99118
return session
100119

101120

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

108177

109178
def login_error(parsed_response):
@@ -117,6 +186,8 @@ def login_error(parsed_response):
117186
"googleauthrequired": LastPassIncorrectGoogleAuthenticatorCodeError,
118187
"googleauthfailed": LastPassIncorrectGoogleAuthenticatorCodeError,
119188
"yubikeyrestricted": LastPassIncorrectYubikeyPasswordError,
189+
"outofbandrequired": LastPassIncorrectOutOfBandRequiredError,
190+
"multifactorresponsefailed": LastPassIncorrectMultiFactorResponseError,
120191
}
121192

122193
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)