Skip to content

Commit 6f6b041

Browse files
Merge pull request #502 from Nitrokey/nkpk-update
nkpk: Add fetch-update, validate-update commands
2 parents 8e11334 + a4b82ce commit 6f6b041

File tree

10 files changed

+263
-163
lines changed

10 files changed

+263
-163
lines changed

pynitrokey/cli/nk3/__init__.py

+4-100
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
# http://opensource.org/licenses/MIT>, at your option. This file may not be
88
# copied, modified, or distributed except according to those terms.
99

10-
import os.path
1110
import sys
1211
from typing import List, Optional
1312

@@ -16,26 +15,17 @@
1615
from pynitrokey.cli import trussed
1716
from pynitrokey.cli.exceptions import CliException
1817
from pynitrokey.cli.trussed.test import TestCase
19-
from pynitrokey.helpers import DownloadProgressBar, check_experimental_flag, local_print
18+
from pynitrokey.helpers import check_experimental_flag
19+
from pynitrokey.nk3 import NK3_DATA
2020
from pynitrokey.nk3.bootloader import Nitrokey3Bootloader
2121
from pynitrokey.nk3.device import Nitrokey3Device
22-
from pynitrokey.nk3.updates import REPOSITORY, get_firmware_update
2322
from pynitrokey.trussed.base import NitrokeyTrussedBase
24-
from pynitrokey.trussed.bootloader import (
25-
Device,
26-
FirmwareContainer,
27-
parse_firmware_image,
28-
)
29-
from pynitrokey.updates import OverwriteError
23+
from pynitrokey.trussed.bootloader import Device
3024

3125

3226
class Context(trussed.Context[Nitrokey3Bootloader, Nitrokey3Device]):
3327
def __init__(self, path: Optional[str]) -> None:
34-
super().__init__(path, Nitrokey3Bootloader, Nitrokey3Device) # type: ignore[type-abstract]
35-
36-
@property
37-
def device_name(self) -> str:
38-
return "Nitrokey 3"
28+
super().__init__(path, Nitrokey3Bootloader, Nitrokey3Device, Device.NITROKEY3, NK3_DATA) # type: ignore[type-abstract]
3929

4030
@property
4131
def test_cases(self) -> list[TestCase]:
@@ -79,92 +69,6 @@ def _list() -> None:
7969
trussed._list(Context(None))
8070

8171

82-
@nk3.command()
83-
@click.argument("path", default=".")
84-
@click.option(
85-
"-f",
86-
"--force",
87-
is_flag=True,
88-
default=False,
89-
help="Overwrite the firmware image if it already exists",
90-
)
91-
@click.option("--version", help="Download this version instead of the latest one")
92-
def fetch_update(path: str, force: bool, version: Optional[str]) -> None:
93-
"""
94-
Fetches a firmware update for the Nitrokey 3 and stores it at the given path.
95-
96-
If no path is given, the firmware image stored in the current working
97-
directory. If the given path is a directory, the image is stored under
98-
that directory. Otherwise it is written to the path. Existing files are
99-
only overwritten if --force is set.
100-
101-
Per default, the latest firmware release is fetched. If you want to
102-
download a specific version, use the --version option.
103-
"""
104-
try:
105-
release = REPOSITORY.get_release_or_latest(version)
106-
update = get_firmware_update(release)
107-
except Exception as e:
108-
if version:
109-
raise CliException(f"Failed to find firmware update {version}", e)
110-
else:
111-
raise CliException("Failed to find latest firmware update", e)
112-
113-
bar = DownloadProgressBar(desc=update.tag)
114-
115-
try:
116-
if os.path.isdir(path):
117-
path = update.download_to_dir(path, overwrite=force, callback=bar.update)
118-
else:
119-
if not force and os.path.exists(path):
120-
raise OverwriteError(path)
121-
with open(path, "wb") as f:
122-
update.download(f, callback=bar.update)
123-
124-
bar.close()
125-
126-
local_print(f"Successfully downloaded firmware release {update.tag} to {path}")
127-
except OverwriteError as e:
128-
raise CliException(
129-
f"{e.path} already exists. Use --force to overwrite the file.",
130-
support_hint=False,
131-
)
132-
except Exception as e:
133-
raise CliException(f"Failed to download firmware update {update.tag}", e)
134-
135-
136-
@nk3.command()
137-
@click.argument("image", type=click.Path(exists=True, dir_okay=False))
138-
def validate_update(image: str) -> None:
139-
"""
140-
Validates the given firmware image and prints the firmware version and the signer for all
141-
available variants.
142-
"""
143-
container = FirmwareContainer.parse(image, Device.NITROKEY3)
144-
print(f"version: {container.version}")
145-
if container.pynitrokey:
146-
print(f"pynitrokey: >= {container.pynitrokey}")
147-
148-
for variant in container.images:
149-
data = container.images[variant]
150-
try:
151-
metadata = parse_firmware_image(variant, data)
152-
except Exception as e:
153-
raise CliException("Failed to parse and validate firmware image", e)
154-
155-
signed_by = metadata.signed_by or "unsigned"
156-
157-
print(f"variant: {variant.value}")
158-
print(f" version: {metadata.version}")
159-
print(f" signed by: {signed_by}")
160-
161-
if container.version != metadata.version:
162-
raise CliException(
163-
f"The firmware image for the {variant} variant and the release "
164-
f"{container.version} has an unexpected product version ({metadata.version})."
165-
)
166-
167-
16872
@nk3.command()
16973
@click.argument("image", type=click.Path(exists=True, dir_okay=False), required=False)
17074
@click.option(

pynitrokey/cli/nkpk.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,31 @@
77
# http://opensource.org/licenses/MIT>, at your option. This file may not be
88
# copied, modified, or distributed except according to those terms.
99

10+
import re
1011
from typing import Optional, Sequence
1112

1213
import click
1314

1415
from pynitrokey.cli.trussed.test import TestCase
1516
from pynitrokey.helpers import local_print
16-
from pynitrokey.nkpk import NitrokeyPasskeyBootloader, NitrokeyPasskeyDevice
17+
from pynitrokey.nkpk import NKPK_DATA, NitrokeyPasskeyBootloader, NitrokeyPasskeyDevice
1718
from pynitrokey.trussed.base import NitrokeyTrussedBase
19+
from pynitrokey.trussed.bootloader import Device
1820
from pynitrokey.trussed.device import NitrokeyTrussedDevice
21+
from pynitrokey.updates import Repository
1922

2023
from . import trussed
2124

2225

2326
class Context(trussed.Context[NitrokeyPasskeyBootloader, NitrokeyPasskeyDevice]):
2427
def __init__(self, path: Optional[str]) -> None:
25-
super().__init__(path, NitrokeyPasskeyBootloader, NitrokeyPasskeyDevice)
28+
super().__init__(
29+
path,
30+
NitrokeyPasskeyBootloader,
31+
NitrokeyPasskeyDevice,
32+
Device.NITROKEY_PASSKEY,
33+
NKPK_DATA,
34+
)
2635

2736
@property
2837
def test_cases(self) -> list[TestCase]:

pynitrokey/cli/trussed/__init__.py

+123-14
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
# copied, modified, or distributed except according to those terms.
99

1010
import logging
11+
import os.path
1112
from abc import ABC, abstractmethod
1213
from hashlib import sha256
14+
from re import Pattern
1315
from typing import BinaryIO, Callable, Generic, Optional, Sequence, TypeVar
1416

1517
import click
@@ -19,13 +21,25 @@
1921
from ecdsa import NIST256p, SigningKey
2022

2123
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
2331
from pynitrokey.trussed.admin_app import BootMode
2432
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+
)
2639
from pynitrokey.trussed.device import NitrokeyTrussedDevice
2740
from pynitrokey.trussed.exceptions import TimeoutException
2841
from pynitrokey.trussed.provisioner_app import ProvisionerApp
42+
from pynitrokey.updates import OverwriteError, Repository
2943

3044
from .test import TestCase
3145

@@ -42,15 +56,14 @@ def __init__(
4256
path: Optional[str],
4357
bootloader_type: type[Bootloader],
4458
device_type: type[Device],
59+
bootloader_device: BootloaderDevice,
60+
data: DeviceData,
4561
) -> None:
4662
self.path = path
4763
self.bootloader_type = bootloader_type
4864
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
5467

5568
@property
5669
@abstractmethod
@@ -76,20 +89,20 @@ def list(self) -> Sequence[NitrokeyTrussedBase]:
7689
return self.list_all()
7790

7891
def connect(self) -> NitrokeyTrussedBase:
79-
return self._select_unique(self.device_name, self.list())
92+
return self._select_unique(self.data.name, self.list())
8093

8194
def connect_device(self) -> Device:
8295
devices = [
8396
device for device in self.list() if isinstance(device, self.device_type)
8497
]
85-
return self._select_unique(self.device_name, devices)
98+
return self._select_unique(self.data.name, devices)
8699

87100
def await_device(
88101
self,
89102
retries: Optional[int] = None,
90103
callback: Optional[Callable[[int, int], None]] = None,
91104
) -> 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)
93106

94107
def await_bootloader(
95108
self,
@@ -98,7 +111,7 @@ def await_bootloader(
98111
) -> Bootloader:
99112
# mypy does not allow abstract types here, but this is still valid
100113
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
102115
)
103116

104117
def _select_unique(self, name: str, devices: Sequence[T]) -> T:
@@ -146,15 +159,74 @@ def prepare_group() -> None:
146159

147160

148161
def add_commands(group: click.Group) -> None:
162+
group.add_command(fetch_update)
149163
group.add_command(list)
150164
group.add_command(provision)
151165
group.add_command(reboot)
152166
group.add_command(rng)
153167
group.add_command(status)
154168
group.add_command(test)
169+
group.add_command(validate_update)
155170
group.add_command(version)
156171

157172

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+
158230
@click.command()
159231
@click.pass_obj
160232
def list(ctx: Context[Bootloader, Device]) -> None:
@@ -163,7 +235,7 @@ def list(ctx: Context[Bootloader, Device]) -> None:
163235

164236

165237
def _list(ctx: Context[Bootloader, Device]) -> None:
166-
local_print(f":: '{ctx.device_name}' keys")
238+
local_print(f":: '{ctx.data.name}' keys")
167239
for device in ctx.list_all():
168240
with device as device:
169241
uuid = device.uuid()
@@ -432,9 +504,9 @@ def test(
432504

433505
if len(devices) == 0:
434506
log_devices()
435-
raise CliException(f"No connected {ctx.device_name} devices found")
507+
raise CliException(f"No connected {ctx.data.name} devices found")
436508

437-
local_print(f"Found {len(devices)} {ctx.device_name} device(s):")
509+
local_print(f"Found {len(devices)} {ctx.data.name} device(s):")
438510
for device in devices:
439511
local_print(f"- {device.name} at {device.path}")
440512

@@ -463,6 +535,43 @@ def test(
463535
raise CliException(f"Test failed for {failure} device(s)")
464536

465537

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+
466575
@click.command()
467576
@click.pass_obj
468577
def version(ctx: Context[Bootloader, Device]) -> None:

0 commit comments

Comments
 (0)