-
Notifications
You must be signed in to change notification settings - Fork 8
/
nanopass.py
executable file
·324 lines (289 loc) · 9.93 KB
/
nanopass.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
#!/usr/bin/python3
#
# Copyright 2020 Ledger SAS
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from random import getrandbits as rnd
from binascii import hexlify, unhexlify
import click
import binascii
import json
from typing import Optional, List, Tuple
import ledgerwallet.client
MAX_NAME_LEN = 32
MAX_LOGIN_LEN = 32
MAX_PASS_LEN = 32
class BadVersion(Exception):
pass
def str_to_bytes_pad(s, size):
result = bytearray(s.encode())
assert len(result) <= size
while len(result) < size:
result.append(0)
return result
def bytes_to_str(data):
while (len(data) > 0) and (data[-1] == 0):
data = data[:-1]
return data.decode()
class Client:
def __init__(self, dev):
"""
Connects to a device.
:param dev: Instance which implements the communication with the device.
"""
self.dev = dev
self.dev.cla = 0x80
def open_app(self):
app_name = "nanopass".encode()
self.dev.cla = 0xe0
try:
# Due to current limitation, app won't reply to this APDU and an
# OSError is thrown as the USB device disconnects. Handle it
# silently.
# This needs to be improved
self.dev.apdu_exchange(0xd8, app_name)
finally:
self.dev.cla = 0x80
def quit_app(self):
try:
self.dev.apdu_exchange(0x0c)
except OSError as e:
pass
def get_version(self) -> str:
""" :return: App version string """
resp = self.dev.apdu_exchange(0x01)
offset = 0
assert resp[offset] == 1 # Check format
offset += 1
length = resp[offset] # App name length
offset += 1
value = resp[offset:offset+length]
assert value == b"nanopass"
offset += length
length = resp[offset] # App version string length
offset += 1
value = resp[offset:offset+length]
return value.decode()
def get_size(self) -> int:
"""
:return: Number of password entries.
"""
resp = self.dev.apdu_exchange(0x02)
assert len(resp) == 4
return int.from_bytes(resp, 'big')
def add(self, name: str, login: str, password: Optional[str] = None):
"""
Add a new password.
:param name: Password name.
:param login: Password login.
:param password: Password. None if it is generated by the device.
"""
name_bytes = str_to_bytes_pad(name, MAX_NAME_LEN)
login_bytes = str_to_bytes_pad(login, MAX_LOGIN_LEN)
if password is not None:
p1 = 0x00
password_bytes = str_to_bytes_pad(password, MAX_PASS_LEN)
else:
p1 = 0x01
password_bytes = bytearray()
self.dev.apdu_exchange(0x03, p1=p1, data=name_bytes + login_bytes +
password_bytes)
def get_name(self, index: int) -> str:
"""
Retrieve name of a password
:param index: Password entry index
:return: Name
"""
r = self.dev.apdu_exchange(0x04, index.to_bytes(4, 'big'))
assert len(r) == 32
return bytes_to_str(r)
def get_names(self) -> List[str]:
""" :return: List of password names """
return [self.get_name(i) for i in range(self.get_size())]
def get_by_name(self, name: str) -> Tuple[str, str]:
"""
Retrieve the password with the given name.
:param name: Password name.
:return: Login and Password string tuple.
"""
name_bytes = str_to_bytes_pad(name, MAX_NAME_LEN)
r = self.dev.apdu_exchange(0x05, name_bytes)
login = bytes_to_str(r[:32])
password = bytes_to_str(r[32:32+64])
return (login, password)
def get_by_name_internal(self, name: str):
"""
Ask the device to display on screen the login and password with the
given name. Using this method, no sensitive information is transfered to
the computer.
:param name: Password name.
"""
name_bytes = str_to_bytes_pad(name, MAX_NAME_LEN)
self.dev.apdu_exchange(0x0d, name_bytes)
def delete_by_name(self, name: str):
"""
Remove a password.
:param name: Password name.
"""
name_bytes = str_to_bytes_pad(name, MAX_NAME_LEN)
self.dev.apdu_exchange(0x06, name_bytes)
def export(self, encrypt: bool=True) -> List[bytes]:
"""
Export passwords.
:param encrypt: True to encrypt passwords during export, False to export
in plaintext.
:return: Exported entries.
"""
p1 = 0x01
if not encrypt:
p1 = 0x00
count = int.from_bytes(self.dev.apdu_exchange(0x07, p1=p1), 'big')
entries = []
for i in range(count):
entries.append(self.dev.apdu_exchange(0x08))
return entries
def import_(self, version, entries: List[bytes], encrypted: bool):
"""
Import password entries.
:param version: Export file version, used for migration.
:param entries: Password entries to be imported.
:param encrypted: True if the entries are encrypted, False if it is in
plaintext.
"""
# We don't support import on 1.0.0 anymore.
# App must be upgraded. Password exports from 1.0.0 can be imported.
if self.get_version() < "1.1.0":
raise BadVersion("App version must be >= 1.1.0")
# We cannot import files from 1.0.0 if they are encrypted.
if encrypted and (version < "1.1.0"):
raise BadVersion("Cannot import version < 1.1.0 encrypted exports")
apdu = bytearray(b'\x80\x09\x00\x00\x04' +
len(entries).to_bytes(4, 'big'))
p1 = 0x00
if encrypted:
p1 = 0x01
r = self.dev.apdu_exchange(
0x09, p1=p1, data=len(entries).to_bytes(4, 'big'))
for p in entries:
if version < "1.1.0":
# Patch the data blob to add login
assert encrypted == False
p = p[:32] + (b"\x00" * 32) + p[32:64]
assert len(p) == {True: 16+96+16, False: 96}[encrypted]
self.dev.apdu_exchange(0x0a, p)
def clear(self):
""" Remove all passwords """
self.dev.apdu_exchange(0x0b)
def has_name(self, name: str):
""" Query if a password with the given name exists. """
name_bytes = str_to_bytes_pad(name, MAX_NAME_LEN)
res = self.dev.apdu_exchange(0x0e, name_bytes)
assert len(res) == 1
assert res[0] in (0, 1)
return bool(res[0])
@click.group()
@click.pass_context
def cli(ctx):
ctx.ensure_object(dict)
dev = ledgerwallet.client.LedgerClient()
ctx.obj['DEV'] = Client(dev)
@cli.command(help="Print installed application version")
@click.pass_context
def version(ctx):
dev = ctx.obj['DEV']
print(dev.get_version())
@cli.command(help="Inserts a new password")
@click.argument('name')
@click.option('--login', default="")
@click.pass_context
def insert(ctx, name, login):
password = input("Password (empty to generate):")
if len(password) == 0:
password = None
print("Confirm password creation on your device...")
dev = ctx.obj['DEV']
dev.add(name, login, password)
@cli.command(help="Print a stored password")
@click.pass_context
@click.argument('name')
def get(ctx, name):
dev = ctx.obj['DEV']
if not dev.has_name(name):
print("Credentials not found")
return
print("Confirm access on device...")
login, password = dev.get_by_name(name)
if len(login):
print("login:", login)
print("password:", password)
@cli.command(help="Print a stored password on the device")
@click.pass_context
@click.argument('name')
def getinternal(ctx, name):
print("Confirm password display on your device...")
dev = ctx.obj['DEV']
print(dev.get_by_name_internal(name))
@cli.command(help="List the names of stored passwords")
@click.pass_context
def list(ctx):
dev = ctx.obj['DEV']
entries = dev.get_names()
entries.sort()
for entry in entries:
print('-', entry)
@cli.command(help="Remove a password from the store")
@click.pass_context
@click.argument('name')
def remove(ctx, name):
dev = ctx.obj['DEV']
dev.delete_by_name(name)
@cli.command(help="Export passwords to JSON file")
@click.argument('path')
@click.option('--encrypt/--no-encrypt', default=True)
@click.pass_context
def export(ctx, path, encrypt):
dev = ctx.obj['DEV']
entries = dev.export(encrypt)
export = {
'version': dev.get_version(),
'encrypted': encrypt,
'entries': [binascii.hexlify(e).decode() for e in entries]
}
with open(path, 'wb') as f:
f.write(json.dumps(export, indent=2).encode())
@cli.command(name='import', help="Import passwords from JSON file")
@click.argument('path')
@click.pass_context
def import_(ctx, path):
dev = ctx.obj['DEV']
data = json.loads(open(path, 'rb').read().decode())
entries = [bytes.fromhex(e) for e in data['entries']]
encrypted = data['encrypted']
dev.import_(data['version'], entries, encrypted)
@cli.command(help="Clear all passwords")
@click.pass_context
def clear(ctx):
dev = ctx.obj['DEV']
dev.clear()
@cli.command(name='open', help="Open application")
@click.pass_context
def open_(ctx):
dev = ctx.obj['DEV']
dev.open_app()
@cli.command(help="Quit application")
@click.pass_context
def quit(ctx):
dev = ctx.obj['DEV']
dev.quit_app()
if __name__ == '__main__':
cli()