8
8
# copied, modified, or distributed except according to those terms.
9
9
10
10
import logging
11
+ import os .path
11
12
from abc import ABC , abstractmethod
12
13
from hashlib import sha256
14
+ from re import Pattern
13
15
from typing import BinaryIO , Callable , Generic , Optional , Sequence , TypeVar
14
16
15
17
import click
19
21
from ecdsa import NIST256p , SigningKey
20
22
21
23
from pynitrokey .cli .exceptions import CliException
22
- from pynitrokey .helpers import Retries , local_print , require_windows_admin
24
+ from pynitrokey .helpers import (
25
+ DownloadProgressBar ,
26
+ Retries ,
27
+ local_print ,
28
+ require_windows_admin ,
29
+ )
30
+ from pynitrokey .trussed import DeviceData
23
31
from pynitrokey .trussed .admin_app import BootMode
24
32
from pynitrokey .trussed .base import NitrokeyTrussedBase
25
- from pynitrokey .trussed .bootloader import NitrokeyTrussedBootloader
33
+ from pynitrokey .trussed .bootloader import Device as BootloaderDevice
34
+ from pynitrokey .trussed .bootloader import (
35
+ FirmwareContainer ,
36
+ NitrokeyTrussedBootloader ,
37
+ parse_firmware_image ,
38
+ )
26
39
from pynitrokey .trussed .device import NitrokeyTrussedDevice
27
40
from pynitrokey .trussed .exceptions import TimeoutException
28
41
from pynitrokey .trussed .provisioner_app import ProvisionerApp
42
+ from pynitrokey .updates import OverwriteError , Repository
29
43
30
44
from .test import TestCase
31
45
@@ -42,15 +56,14 @@ def __init__(
42
56
path : Optional [str ],
43
57
bootloader_type : type [Bootloader ],
44
58
device_type : type [Device ],
59
+ bootloader_device : BootloaderDevice ,
60
+ data : DeviceData ,
45
61
) -> None :
46
62
self .path = path
47
63
self .bootloader_type = bootloader_type
48
64
self .device_type = device_type
49
-
50
- @property
51
- @abstractmethod
52
- def device_name (self ) -> str :
53
- ...
65
+ self .bootloader_device = bootloader_device
66
+ self .data = data
54
67
55
68
@property
56
69
@abstractmethod
@@ -76,20 +89,20 @@ def list(self) -> Sequence[NitrokeyTrussedBase]:
76
89
return self .list_all ()
77
90
78
91
def connect (self ) -> NitrokeyTrussedBase :
79
- return self ._select_unique (self .device_name , self .list ())
92
+ return self ._select_unique (self .data . name , self .list ())
80
93
81
94
def connect_device (self ) -> Device :
82
95
devices = [
83
96
device for device in self .list () if isinstance (device , self .device_type )
84
97
]
85
- return self ._select_unique (self .device_name , devices )
98
+ return self ._select_unique (self .data . name , devices )
86
99
87
100
def await_device (
88
101
self ,
89
102
retries : Optional [int ] = None ,
90
103
callback : Optional [Callable [[int , int ], None ]] = None ,
91
104
) -> Device :
92
- return self ._await (self .device_name , self .device_type , retries , callback )
105
+ return self ._await (self .data . name , self .device_type , retries , callback )
93
106
94
107
def await_bootloader (
95
108
self ,
@@ -98,7 +111,7 @@ def await_bootloader(
98
111
) -> Bootloader :
99
112
# mypy does not allow abstract types here, but this is still valid
100
113
return self ._await (
101
- f"{ self .device_name } bootloader" , self .bootloader_type , retries , callback
114
+ f"{ self .data . name } bootloader" , self .bootloader_type , retries , callback
102
115
)
103
116
104
117
def _select_unique (self , name : str , devices : Sequence [T ]) -> T :
@@ -146,15 +159,74 @@ def prepare_group() -> None:
146
159
147
160
148
161
def add_commands (group : click .Group ) -> None :
162
+ group .add_command (fetch_update )
149
163
group .add_command (list )
150
164
group .add_command (provision )
151
165
group .add_command (reboot )
152
166
group .add_command (rng )
153
167
group .add_command (status )
154
168
group .add_command (test )
169
+ group .add_command (validate_update )
155
170
group .add_command (version )
156
171
157
172
173
+ @click .command ()
174
+ @click .argument ("path" , default = "." )
175
+ @click .option (
176
+ "-f" ,
177
+ "--force" ,
178
+ is_flag = True ,
179
+ default = False ,
180
+ help = "Overwrite the firmware image if it already exists" ,
181
+ )
182
+ @click .option ("--version" , help = "Download this version instead of the latest one" )
183
+ @click .pass_obj
184
+ def fetch_update (
185
+ ctx : Context [Bootloader , Device ], path : str , force : bool , version : Optional [str ]
186
+ ) -> None :
187
+ """
188
+ Fetches a firmware update and stores it at the given path.
189
+
190
+ If no path is given, the firmware image stored in the current working
191
+ directory. If the given path is a directory, the image is stored under
192
+ that directory. Otherwise it is written to the path. Existing files are
193
+ only overwritten if --force is set.
194
+
195
+ Per default, the latest firmware release is fetched. If you want to
196
+ download a specific version, use the --version option.
197
+ """
198
+ try :
199
+ release = ctx .data .firmware_repository .get_release_or_latest (version )
200
+ update = release .require_asset (ctx .data .firmware_pattern )
201
+ except Exception as e :
202
+ if version :
203
+ raise CliException (f"Failed to find firmware update { version } " , e )
204
+ else :
205
+ raise CliException ("Failed to find latest firmware update" , e )
206
+
207
+ bar = DownloadProgressBar (desc = update .tag )
208
+
209
+ try :
210
+ if os .path .isdir (path ):
211
+ path = update .download_to_dir (path , overwrite = force , callback = bar .update )
212
+ else :
213
+ if not force and os .path .exists (path ):
214
+ raise OverwriteError (path )
215
+ with open (path , "wb" ) as f :
216
+ update .download (f , callback = bar .update )
217
+
218
+ bar .close ()
219
+
220
+ local_print (f"Successfully downloaded firmware release { update .tag } to { path } " )
221
+ except OverwriteError as e :
222
+ raise CliException (
223
+ f"{ e .path } already exists. Use --force to overwrite the file." ,
224
+ support_hint = False ,
225
+ )
226
+ except Exception as e :
227
+ raise CliException (f"Failed to download firmware update { update .tag } " , e )
228
+
229
+
158
230
@click .command ()
159
231
@click .pass_obj
160
232
def list (ctx : Context [Bootloader , Device ]) -> None :
@@ -163,7 +235,7 @@ def list(ctx: Context[Bootloader, Device]) -> None:
163
235
164
236
165
237
def _list (ctx : Context [Bootloader , Device ]) -> None :
166
- local_print (f":: '{ ctx .device_name } ' keys" )
238
+ local_print (f":: '{ ctx .data . name } ' keys" )
167
239
for device in ctx .list_all ():
168
240
with device as device :
169
241
uuid = device .uuid ()
@@ -432,9 +504,9 @@ def test(
432
504
433
505
if len (devices ) == 0 :
434
506
log_devices ()
435
- raise CliException (f"No connected { ctx .device_name } devices found" )
507
+ raise CliException (f"No connected { ctx .data . name } devices found" )
436
508
437
- local_print (f"Found { len (devices )} { ctx .device_name } device(s):" )
509
+ local_print (f"Found { len (devices )} { ctx .data . name } device(s):" )
438
510
for device in devices :
439
511
local_print (f"- { device .name } at { device .path } " )
440
512
@@ -463,6 +535,43 @@ def test(
463
535
raise CliException (f"Test failed for { failure } device(s)" )
464
536
465
537
538
+ @click .command ()
539
+ @click .argument ("image" , type = click .Path (exists = True , dir_okay = False ))
540
+ @click .pass_obj
541
+ def validate_update (ctx : Context [Bootloader , Device ], image : str ) -> None :
542
+ """
543
+ Validates the given firmware image and prints the firmware version and the signer for all
544
+ available variants.
545
+ """
546
+ try :
547
+ container = FirmwareContainer .parse (image , ctx .bootloader_device )
548
+ except ValueError as e :
549
+ raise CliException ("Failed to validate firmware image" , e , support_hint = False )
550
+
551
+ print (f"version: { container .version } " )
552
+ if container .pynitrokey :
553
+ print (f"pynitrokey: >= { container .pynitrokey } " )
554
+
555
+ for variant in container .images :
556
+ data = container .images [variant ]
557
+ try :
558
+ metadata = parse_firmware_image (variant , data , ctx .data )
559
+ except Exception as e :
560
+ raise CliException ("Failed to parse and validate firmware image" , e )
561
+
562
+ signed_by = metadata .signed_by or "unsigned"
563
+
564
+ print (f"variant: { variant .value } " )
565
+ print (f" version: { metadata .version } " )
566
+ print (f" signed by: { signed_by } " )
567
+
568
+ if container .version != metadata .version :
569
+ raise CliException (
570
+ f"The firmware image for the { variant } variant and the release "
571
+ f"{ container .version } has an unexpected product version ({ metadata .version } )."
572
+ )
573
+
574
+
466
575
@click .command ()
467
576
@click .pass_obj
468
577
def version (ctx : Context [Bootloader , Device ]) -> None :
0 commit comments