Skip to content

Commit 1176fdf

Browse files
committed
Add PIV tab with key generation and self-signed certificate support
Implements a full PIV tab for Nitrokey 3 devices: slot listing, PIN/PUK management, PKCS#12 import, and key generation. After generating a key, a self-signed X.509 certificate is written to the slot automatically (matching pynitrokey CLI behaviour) so the slot shows as occupied rather than empty. Handles NK3-specific behaviour where the PIV applet resets PIN authentication after a GENERATE KEY APDU.
1 parent 674531e commit 1176fdf

5 files changed

Lines changed: 87 additions & 57 deletions

File tree

nitrokeyapp/gui.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@
1515
from nitrokeyapp.device_view import DeviceView
1616
from nitrokeyapp.error_dialog import ErrorDialog
1717
from nitrokeyapp.fido2_tab import Fido2Tab
18-
from nitrokeyapp.piv_tab import PivTab
1918
from nitrokeyapp.information_box import InfoBox
2019
from nitrokeyapp.nk3_button import Nk3Button
2120
from nitrokeyapp.overview_tab import OverviewTab
21+
from nitrokeyapp.piv_tab import PivTab
2222
from nitrokeyapp.progress_box import ProgressBox
2323
from nitrokeyapp.prompt_box import PromptBox
2424
from nitrokeyapp.qt_utils_mix_in import QtUtilsMixIn
@@ -304,7 +304,7 @@ def show_device(self, data: DeviceData) -> None:
304304
self.welcome_widget.hide()
305305

306306
# enforce refreshing the current view
307-
self.views[0].refresh(data, force=True)
307+
self.views[0].refresh(data)
308308

309309
for btn in self.device_buttons:
310310
btn.set_stylesheet_small()

nitrokeyapp/piv_tab/__init__.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,16 @@
1010

1111
from PySide6.QtCore import Qt, QThread, Signal, Slot
1212
from PySide6.QtWidgets import (
13+
QComboBox,
1314
QDialog,
1415
QDialogButtonBox,
1516
QFileDialog,
1617
QFormLayout,
1718
QInputDialog,
19+
QLabel,
1820
QLineEdit,
1921
QListWidgetItem,
2022
QMessageBox,
21-
QComboBox,
22-
QLabel,
2323
QVBoxLayout,
2424
QWidget,
2525
)
@@ -34,11 +34,7 @@
3434

3535
logger = logging.getLogger(__name__)
3636

37-
ALGO_CHOICES = [
38-
("ECC P-256", b"\x11"),
39-
("ECC P-384", b"\x14"),
40-
("RSA 2048", b"\x07"),
41-
]
37+
ALGO_CHOICES = [("ECC P-256", b"\x11"), ("ECC P-384", b"\x14"), ("RSA 2048", b"\x07")]
4238

4339

4440
class PivTab(QtUtilsMixIn, QWidget):
@@ -297,9 +293,7 @@ def _prompt_import_p12(self) -> None:
297293
password: Optional[bytes] = password_str.encode() if (ok and password_str) else None
298294

299295
admin_key_str, ok = QInputDialog.getText(
300-
self,
301-
"Management Key",
302-
"Management key (hex, leave empty for default):",
296+
self, "Management Key", "Management key (hex, leave empty for default):"
303297
)
304298
if ok and admin_key_str.strip():
305299
try:
@@ -317,6 +311,7 @@ def _prompt_import_p12(self) -> None:
317311

318312
# ── Helper dialog ─────────────────────────────────────────────────────────────
319313

314+
320315
class _GenerateKeyDialog(QDialog):
321316
def __init__(self, slot_id: str, parent: Optional[QWidget] = None) -> None:
322317
super().__init__(parent)

nitrokeyapp/piv_tab/data.py

Lines changed: 70 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@
1313

1414
from cryptography import x509
1515
from cryptography.hazmat.primitives import hashes
16+
from cryptography.hazmat.primitives.asymmetric import ec, rsa
1617
from cryptography.hazmat.primitives.ciphers import Cipher, modes
1718
from cryptography.hazmat.primitives.serialization import Encoding, pkcs12
18-
from cryptography.hazmat.primitives.asymmetric import ec, rsa
1919

2020
try:
2121
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
2222
except ImportError:
23-
from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES # type: ignore[no-reattr]
23+
from cryptography.hazmat.primitives.ciphers.algorithms import (
24+
TripleDES, # type: ignore[no-reattr]
25+
)
2426

2527
logger = logging.getLogger(__name__)
2628

@@ -29,8 +31,23 @@
2931

3032
# SELECT PIV APDU (matches pynitrokey's piv_app.py)
3133
PIV_SELECT_APDU = [
32-
0x00, 0xA4, 0x04, 0x00, 0x0C,
33-
0xA0, 0x00, 0x00, 0x03, 0x08, 0x00, 0x00, 0x10, 0x00, 0x01, 0x00, 0x00,
34+
0x00,
35+
0xA4,
36+
0x04,
37+
0x00,
38+
0x0C,
39+
0xA0,
40+
0x00,
41+
0x00,
42+
0x03,
43+
0x08,
44+
0x00,
45+
0x00,
46+
0x10,
47+
0x00,
48+
0x01,
49+
0x00,
50+
0x00,
3451
]
3552

3653
# Map from slot hex string to certificate container object ID
@@ -62,6 +79,7 @@
6279

6380
# ─── TLV helpers (ported from pynitrokey/tlv.py) ─────────────────────────────
6481

82+
6583
def _tlv_build_one(tag: int, data: bytes) -> bytes:
6684
if tag <= 0xFF:
6785
tag_bytes = bytes([tag])
@@ -132,6 +150,7 @@ def find_by_id(tag: int, items: list[tuple[int, bytes]]) -> Optional[bytes]:
132150

133151
# ─── Data classes ─────────────────────────────────────────────────────────────
134152

153+
135154
@dataclass
136155
class PivCertInfo:
137156
subject: str
@@ -179,6 +198,7 @@ def has_cert(self) -> bool:
179198

180199
# ─── PIV error ────────────────────────────────────────────────────────────────
181200

201+
182202
class PivError(Exception):
183203
def __init__(self, status: int, message: str = "") -> None:
184204
self.status = status
@@ -207,6 +227,7 @@ def pin_retries(self) -> int:
207227

208228
# ─── PivApp ───────────────────────────────────────────────────────────────────
209229

230+
210231
class PivApp:
211232
"""PIV APDU session over a smartcard (pyscard) connection."""
212233

@@ -234,14 +255,16 @@ def __exit__(self, *args: Any) -> None:
234255
def open(cls) -> "PivApp":
235256
"""Open a PIV session on the first NK3 found via smartcard interface."""
236257
try:
237-
from smartcard.Exceptions import CardConnectionException, NoCardException
238-
from smartcard.ExclusiveTransmitCardConnection import ExclusiveTransmitCardConnection
239-
from smartcard.System import readers as list_readers
240-
except ImportError:
241-
raise PivError(
242-
0x0000,
243-
"pyscard is not installed. Run: pip install pyscard",
258+
from smartcard.Exceptions import ( # type: ignore[import-untyped]
259+
CardConnectionException,
260+
NoCardException,
261+
)
262+
from smartcard.ExclusiveTransmitCardConnection import ( # type: ignore[import-untyped]
263+
ExclusiveTransmitCardConnection,
244264
)
265+
from smartcard.System import readers as list_readers # type: ignore[import-untyped]
266+
except ImportError:
267+
raise PivError(0x0000, "pyscard is not installed. Run: pip install pyscard") from None
245268

246269
nk3_atr = list(NK3_ATR)
247270
for reader in list_readers():
@@ -316,16 +339,19 @@ def authenticate_admin(self, admin_key: bytes = DEFAULT_ADMIN_KEY) -> None:
316339
"""Mutual-authenticate with the management key (3DES or AES)."""
317340
if len(admin_key) == 24:
318341
from cryptography.hazmat.primitives.ciphers import algorithms as _alg
319-
algorithm = TripleDES(admin_key)
342+
343+
algorithm: Any = TripleDES(admin_key)
320344
algo_byte = 0x03
321345
expected_len = 8
322346
elif len(admin_key) == 16:
323347
from cryptography.hazmat.primitives.ciphers import algorithms as _alg
348+
324349
algorithm = _alg.AES128(admin_key)
325350
algo_byte = 0x08
326351
expected_len = 16
327352
elif len(admin_key) == 32:
328353
from cryptography.hazmat.primitives.ciphers import algorithms as _alg
354+
329355
algorithm = _alg.AES256(admin_key)
330356
algo_byte = 0x0C
331357
expected_len = 16
@@ -493,8 +519,7 @@ def generate_key_and_cert(
493519
if modulus_data is None or exponent_data is None:
494520
raise PivError(0x0000, "No RSA key data in generate key response")
495521
public_key_rsa = rsa.RSAPublicNumbers(
496-
int.from_bytes(exponent_data, "big"),
497-
int.from_bytes(modulus_data, "big"),
522+
int.from_bytes(exponent_data, "big"), int.from_bytes(modulus_data, "big")
498523
).public_key()
499524
signer = _RsaPivSigner(self, key_ref, public_key_rsa)
500525
cert = (
@@ -514,25 +539,36 @@ def generate_key_and_cert(
514539
slot_id_hex = format(key_ref, "02X")
515540
self.write_certificate(slot_id_hex, cert_der)
516541

517-
def import_rsa2048(self, key_ref: int, key: rsa.RSAPrivateNumbers, public_key: rsa.RSAPublicNumbers) -> None:
542+
def import_rsa2048(
543+
self, key_ref: int, key: rsa.RSAPrivateNumbers, public_key: rsa.RSAPublicNumbers
544+
) -> None:
518545
self.send_receive(
519-
0xFE, 0x07, key_ref,
520-
Tlv.build([
521-
(0x01, key.p.to_bytes(128, "big")),
522-
(0x02, key.q.to_bytes(128, "big")),
523-
(0x03, public_key.e.to_bytes((public_key.e.bit_length() + 7) // 8, "big")),
524-
]),
546+
0xFE,
547+
0x07,
548+
key_ref,
549+
Tlv.build(
550+
[
551+
(0x01, key.p.to_bytes(128, "big")),
552+
(0x02, key.q.to_bytes(128, "big")),
553+
(0x03, public_key.e.to_bytes((public_key.e.bit_length() + 7) // 8, "big")),
554+
]
555+
),
525556
)
526557

527558
def write_certificate(self, slot_id: str, cert_der: bytes) -> None:
528559
container_id = KEY_TO_CERT_OBJ_ID[slot_id.upper()]
529-
payload = Tlv.build([
530-
(0x5C, container_id),
531-
(0x53, Tlv.build([(0x70, cert_der), (0x71, bytes([0]))])),
532-
])
560+
payload = Tlv.build(
561+
[(0x5C, container_id), (0x53, Tlv.build([(0x70, cert_der), (0x71, bytes([0]))]))]
562+
)
533563
self.send_receive(0xDB, 0x3F, 0xFF, payload)
534564

535-
def import_p12(self, slot_id: str, p12_data: bytes, password: Optional[bytes], admin_key: bytes = DEFAULT_ADMIN_KEY) -> None:
565+
def import_p12(
566+
self,
567+
slot_id: str,
568+
p12_data: bytes,
569+
password: Optional[bytes],
570+
admin_key: bytes = DEFAULT_ADMIN_KEY,
571+
) -> None:
536572
"""Import a PKCS#12 file (RSA 2048 only) into a slot."""
537573
private_key, certificate, _ = pkcs12.load_key_and_certificates(p12_data, password)
538574

@@ -545,9 +581,7 @@ def import_p12(self, slot_id: str, p12_data: bytes, password: Optional[bytes], a
545581

546582
self.authenticate_admin(admin_key)
547583
self.import_rsa2048(
548-
key_ref,
549-
private_key.private_numbers(),
550-
private_key.public_key().public_numbers(),
584+
key_ref, private_key.private_numbers(), private_key.public_key().public_numbers()
551585
)
552586
self._select_piv()
553587
self.authenticate_admin(admin_key)
@@ -568,16 +602,17 @@ def list_slots(self) -> list[PivSlotInfo]:
568602
logger.warning(f"Failed to parse cert for slot {slot_id}: {e}")
569603
except PivError as e:
570604
logger.debug(f"Error reading slot {slot_id}: {e}")
571-
slots.append(PivSlotInfo(
572-
slot_id=slot_id,
573-
display_name=SLOT_DISPLAY_NAMES[slot_id],
574-
cert=cert_info,
575-
))
605+
slots.append(
606+
PivSlotInfo(
607+
slot_id=slot_id, display_name=SLOT_DISPLAY_NAMES[slot_id], cert=cert_info
608+
)
609+
)
576610
return slots
577611

578612

579613
# ─── Helpers ──────────────────────────────────────────────────────────────────
580614

615+
581616
def _encode_pin(pin: str) -> bytes:
582617
body = pin.encode("utf-8")
583618
if len(body) > 8:
@@ -599,6 +634,7 @@ def _prepare_pkcs1v15_sha256(data: bytes) -> bytes:
599634

600635
# ─── PIV signing proxy classes (wrap device signing for cert generation) ──────
601636

637+
602638
class _RsaPivSigner(rsa.RSAPrivateKey):
603639
def __init__(self, device: PivApp, key_ref: int, public_key: rsa.RSAPublicKey) -> None:
604640
self._device = device

nitrokeyapp/piv_tab/ui.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
class PivPinUi(QObject):
1616
"""Prompt for PIV PIN via a modal dialog (runs in the UI thread)."""
1717

18-
query = Signal(int) # emitted from worker thread; delivered to UI thread
18+
query = Signal(int) # emitted from worker thread; delivered to UI thread
1919
queried = Signal(str)
2020
cancelled = Signal()
2121

22-
def __init__(self, app_widget: QWidget, title: str = "PIV PIN", label: str = "Enter PIV PIN:") -> None:
22+
def __init__(
23+
self, app_widget: QWidget, title: str = "PIV PIN", label: str = "Enter PIV PIN:"
24+
) -> None:
2325
super().__init__(app_widget)
2426
self.app_widget = app_widget
2527
self.title = title
@@ -30,20 +32,15 @@ def __init__(self, app_widget: QWidget, title: str = "PIV PIN", label: str = "En
3032
def _show_dialog(self, retries: int) -> None:
3133
label = f"{self.label} (remaining retries: {retries})" if retries >= 0 else self.label
3234
pin, ok = QInputDialog.getText(
33-
self.app_widget,
34-
self.title,
35-
label,
36-
QLineEdit.EchoMode.Password,
35+
self.app_widget, self.title, label, QLineEdit.EchoMode.Password
3736
)
3837
if ok and pin:
3938
self.queried.emit(pin)
4039
else:
4140
self.cancelled.emit()
4241

4342
def connect_actions(
44-
self,
45-
queried: Optional[Callable[[str], None]],
46-
cancelled: Optional[Callable[[], None]],
43+
self, queried: Optional[Callable[[str], None]], cancelled: Optional[Callable[[], None]]
4744
) -> "PivPinUiConnection":
4845
conn = PivPinUiConnection(self)
4946
if queried:

nitrokeyapp/piv_tab/worker.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from nitrokeyapp.device_data import DeviceData
1818
from nitrokeyapp.worker import Job, Worker
1919

20-
from .data import DEFAULT_ADMIN_KEY, PivApp, PivError, PivSlotInfo
20+
from .data import DEFAULT_ADMIN_KEY, PivApp, PivError
2121
from .ui import PivPinUi, PivPinUiConnection
2222

2323
logger = logging.getLogger(__name__)
@@ -55,6 +55,7 @@ def update(self, data: DeviceData, pin: str) -> None:
5555

5656
# ─── Jobs ─────────────────────────────────────────────────────────────────────
5757

58+
5859
class CheckDeviceJob(Job):
5960
device_checked = Signal(bool)
6061

@@ -271,6 +272,7 @@ def _do_generate(self, pin: str, pin_was_queried: bool = False) -> None:
271272

272273
# ─── Worker ───────────────────────────────────────────────────────────────────
273274

275+
274276
class PivWorker(Worker):
275277
device_checked = Signal(bool)
276278
slots_listed = Signal(list)
@@ -315,7 +317,7 @@ def reset_piv(self, data: DeviceData) -> None:
315317
job.piv_reset.connect(self.piv_reset)
316318
self.run(job)
317319

318-
@Slot(DeviceData, str, bytes, object, bytes)
320+
@Slot(DeviceData, str, bytes, object, bytes) # type: ignore[arg-type]
319321
def import_p12(
320322
self,
321323
data: DeviceData,

0 commit comments

Comments
 (0)