Skip to content

Commit d4aeb5f

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

File tree

5 files changed

+121
-28
lines changed

5 files changed

+121
-28
lines changed

lastpass/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ 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+
5257
class LastPassUnknownError(Error):
5358
"""LastPass error we don't know about"""
5459
pass

lastpass/fetcher.py

Lines changed: 93 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,7 @@
1315
LastPassInvalidPasswordError,
1416
LastPassIncorrectGoogleAuthenticatorCodeError,
1517
LastPassIncorrectYubikeyPasswordError,
18+
LastPassIncorrectOutOfBandRequiredError,
1619
LastPassUnknownError
1720
)
1821
from .session import Session
@@ -21,9 +24,9 @@
2124
http = requests
2225

2326

24-
def login(username, password, multifactor_password=None, client_id=None):
27+
def login(username, password, multifactor_password=None, client_id=None, trust_id=None, trust_me=False):
2528
key_iteration_count = request_iteration_count(username)
26-
return request_login(username, password, key_iteration_count, multifactor_password, client_id)
29+
return request_login(username, password, key_iteration_count, multifactor_password, client_id, trust_id=trust_id, trust_me=trust_me)
2730

2831

2932
def logout(session, web_client=http):
@@ -63,21 +66,31 @@ def request_iteration_count(username, web_client=http):
6366
raise InvalidResponseError('Key iteration count is not positive')
6467

6568

66-
def request_login(username, password, key_iteration_count, multifactor_password=None, client_id=None, web_client=http):
69+
def request_login(username, password, key_iteration_count, multifactor_password=None, client_id=None, web_client=http, trust_id=None, trust_me=False):
6770
body = {
68-
'method': 'mobile',
69-
'web': 1,
70-
'xml': 1,
71+
'method': 'cli',
72+
'xml': 2,
7173
'username': username,
7274
'hash': make_hash(username, password, key_iteration_count),
7375
'iterations': key_iteration_count,
76+
'includeprivatekeyenc': 1,
77+
'outofbandsupported': 1
7478
}
7579

7680
if multifactor_password:
7781
body['otp'] = multifactor_password
7882

83+
# if client_id:
84+
# body['imei'] = client_id
85+
86+
if trust_me and not trust_id:
87+
trust_id = generate_trust_id()
88+
89+
if trust_id:
90+
body['uuid'] = trust_id
91+
7992
if client_id:
80-
body['imei'] = client_id
93+
body['trustlabel'] = client_id
8194

8295
response = web_client.post('https://lastpass.com/login.php',
8396
data=body)
@@ -93,17 +106,84 @@ def request_login(username, password, key_iteration_count, multifactor_password=
93106
if parsed_response is None:
94107
raise InvalidResponseError()
95108

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

101130

102-
def create_session(parsed_response, key_iteration_count):
131+
if trust_me:
132+
body['trustlabel'] = client_id
133+
134+
135+
def oob_login(web_client, parsed_response, body, key_iteration_count, trust_id):
136+
error = None if parsed_response.tag != 'response' else parsed_response.find(
137+
'error')
138+
if 'outofbandname' not in error.attrib or 'capabilities' not in error.attrib:
139+
return (None, parsed_response)
140+
oob_name = error.attrib['outofbandname']
141+
oob_capabilities = error.attrib['capabilities'].split(',')
142+
can_do_passcode = 'passcode' in oob_capabilities
143+
if can_do_passcode and 'outofband' not in oob_capabilities:
144+
return (None, parsed_response)
145+
body['outofbandrequest'] = '1'
146+
# loop waiting for out of band approval, or failure
147+
while True:
148+
response = web_client.post("https://lastpass.com/login.php", data=body)
149+
if response.status_code != requests.codes.ok:
150+
raise NetworkError()
151+
152+
try:
153+
parsed_response = etree.fromstring(response.content)
154+
except etree.ParseError:
155+
parsed_response = None
156+
157+
if parsed_response is None:
158+
raise InvalidResponseError()
159+
160+
session = create_session(parsed_response, key_iteration_count, trust_id)
161+
if session:
162+
return (session, parsed_response)
163+
error = None if parsed_response.tag != 'response' else parsed_response.find(
164+
'error')
165+
if 'cause' in error.attrib and error.attrib['cause'] == 'outofbandrequired':
166+
if 'retryid' in error.attrib:
167+
body['outofbandretryid'] = error.attrib['retryid']
168+
body['outofbandretry'] = "1"
169+
continue
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,7 @@ def login_error(parsed_response):
117197
"googleauthrequired": LastPassIncorrectGoogleAuthenticatorCodeError,
118198
"googleauthfailed": LastPassIncorrectGoogleAuthenticatorCodeError,
119199
"yubikeyrestricted": LastPassIncorrectYubikeyPasswordError,
200+
"outofbandrequired": LastPassIncorrectOutOfBandRequiredError,
120201
}
121202

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

lastpass/session.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
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 \
11+
self.key_iteration_count == other.key_iteration_count and \
12+
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: 7 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'
@@ -162,7 +163,8 @@ def test_fetch_makes_a_get_request(self):
162163
def test_fetch_returns_a_blob(self):
163164
m = mock.Mock()
164165
m.get.return_value = self._http_ok(self.blob_response)
165-
self.assertEqual(fetcher.fetch(self.session, m), self.blob)
166+
returned_blob = fetcher.fetch(self.session, m)
167+
self.assertEqual(returned_blob, self.blob)
166168

167169
def test_fetch_raises_exception_on_http_error(self):
168170
m = mock.Mock()

0 commit comments

Comments
 (0)