Skip to content

Commit ea4c9b3

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

5 files changed

Lines changed: 143 additions & 28 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: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ def setUp(self):
2222
self.blob_bytes = b64decode(self.blob_response)
2323
self.blob = Blob(self.blob_bytes, self.key_iteration_count)
2424

25-
self.login_post_data = {'method': 'mobile',
26-
'web': 1,
27-
'xml': 1,
25+
self.login_post_data = {'method': 'cli',
26+
'xml': 2,
27+
'outofbandsupported': 1,
28+
'includeprivatekeyenc': 1,
2829
'username': self.username,
2930
'hash': self.hash,
3031
'iterations': self.key_iteration_count}
3132

3233
self.device_id = '492378378052455'
3334
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})
35+
self.login_post_data_with_device_id.update({'trustlabel': self.device_id})
3536

3637
self.google_authenticator_code = '12345'
3738
self.yubikey_password = 'emdbwzemyisymdnevznyqhqnklaqheaxszzvtnxjrmkb'
@@ -147,6 +148,14 @@ def test_request_login_raises_an_exception_on_missing_or_incorrect_yubikey_passw
147148
self.assertRaises(lastpass.LastPassIncorrectYubikeyPasswordError,
148149
self._request_login_with_lastpass_error, 'yubikeyrestricted', message)
149150

151+
def test_request_login_raises_an_exception_on_lastpass_authenticator(self):
152+
message = 'Multifactor authentication required! ' \
153+
'Upgrade your browser extension so you can enter it.'
154+
self.assertRaises(lastpass.LastPassIncorrectOutOfBandRequiredError,
155+
self._request_login_with_lastpass_multifactor_required, 'outofbandrequired', message)
156+
self.assertRaises(lastpass.LastPassIncorrectMultiFactorResponseError,
157+
self._request_login_with_lastpass_multifactor_required, 'multifactorresponsefailed', message)
158+
150159
def test_request_login_raises_an_exception_on_unknown_lastpass_error_without_a_message(self):
151160
cause = 'Unknown cause'
152161
self.assertRaises(lastpass.LastPassUnknownError,
@@ -162,7 +171,8 @@ def test_fetch_makes_a_get_request(self):
162171
def test_fetch_returns_a_blob(self):
163172
m = mock.Mock()
164173
m.get.return_value = self._http_ok(self.blob_response)
165-
self.assertEqual(fetcher.fetch(self.session, m), self.blob)
174+
returned_blob = fetcher.fetch(self.session, m)
175+
self.assertEqual(returned_blob, self.blob)
166176

167177
def test_fetch_raises_exception_on_http_error(self):
168178
m = mock.Mock()
@@ -222,9 +232,19 @@ def _lastpass_error(cause, message):
222232
return '<response><error cause="{}" message="{}" /></response>'.format(cause, message)
223233
return '<response><error cause="{}" /></response>'.format(cause)
224234

235+
@staticmethod
236+
def _lastpass_multifactor_required(cause, message):
237+
if message:
238+
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)
239+
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)
240+
225241
def _request_login_with_lastpass_error(self, cause, message=None):
226242
return self._request_login_with_xml(self._lastpass_error(cause, message))
227243

244+
def _request_login_with_lastpass_multifactor_required(self, cause, message=None):
245+
return self._request_login_with_xml(
246+
self._lastpass_multifactor_required(cause, message))
247+
228248
def _request_login_with_xml(self, text):
229249
return self._request_login_with_ok(text)
230250

0 commit comments

Comments
 (0)