cryptnox-id-cli 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. cryptnox_id_cli/__init__.py +22 -0
  2. cryptnox_id_cli/__main__.py +6 -0
  3. cryptnox_id_cli/applets/__init__.py +2 -0
  4. cryptnox_id_cli/applets/fido/__init__.py +7 -0
  5. cryptnox_id_cli/applets/fido/authdata.py +81 -0
  6. cryptnox_id_cli/applets/fido/constants.py +155 -0
  7. cryptnox_id_cli/applets/fido/ctap.py +430 -0
  8. cryptnox_id_cli/applets/fido/errors.py +90 -0
  9. cryptnox_id_cli/applets/fido/pinproto.py +166 -0
  10. cryptnox_id_cli/applets/genuine/__init__.py +12 -0
  11. cryptnox_id_cli/applets/genuine/constants.py +32 -0
  12. cryptnox_id_cli/applets/genuine/genuine.py +85 -0
  13. cryptnox_id_cli/applets/genuine/verify.py +150 -0
  14. cryptnox_id_cli/applets/mifare/__init__.py +10 -0
  15. cryptnox_id_cli/applets/mifare/desfire.py +353 -0
  16. cryptnox_id_cli/applets/mifare/ev2.py +421 -0
  17. cryptnox_id_cli/applets/piv/__init__.py +9 -0
  18. cryptnox_id_cli/applets/piv/admin.py +182 -0
  19. cryptnox_id_cli/applets/piv/apt.py +58 -0
  20. cryptnox_id_cli/applets/piv/constants.py +66 -0
  21. cryptnox_id_cli/applets/piv/keyimport.py +244 -0
  22. cryptnox_id_cli/applets/piv/objects.py +126 -0
  23. cryptnox_id_cli/applets/piv/perso.py +138 -0
  24. cryptnox_id_cli/applets/piv/piv.py +171 -0
  25. cryptnox_id_cli/applets/piv/preperso.py +174 -0
  26. cryptnox_id_cli/applets/piv/profiles.py +486 -0
  27. cryptnox_id_cli/applets/piv/slots.py +44 -0
  28. cryptnox_id_cli/cli/__init__.py +1 -0
  29. cryptnox_id_cli/cli/commands/__init__.py +1 -0
  30. cryptnox_id_cli/cli/commands/apdu.py +123 -0
  31. cryptnox_id_cli/cli/commands/doctor.py +144 -0
  32. cryptnox_id_cli/cli/commands/factory.py +312 -0
  33. cryptnox_id_cli/cli/commands/fido.py +729 -0
  34. cryptnox_id_cli/cli/commands/genuine.py +192 -0
  35. cryptnox_id_cli/cli/commands/info.py +94 -0
  36. cryptnox_id_cli/cli/commands/mifare.py +998 -0
  37. cryptnox_id_cli/cli/commands/piv.py +2558 -0
  38. cryptnox_id_cli/cli/commands/readers.py +64 -0
  39. cryptnox_id_cli/cli/commands/report.py +217 -0
  40. cryptnox_id_cli/cli/commands/shell.py +130 -0
  41. cryptnox_id_cli/cli/context.py +71 -0
  42. cryptnox_id_cli/cli/dryrun.py +181 -0
  43. cryptnox_id_cli/cli/main.py +117 -0
  44. cryptnox_id_cli/crypto/__init__.py +1 -0
  45. cryptnox_id_cli/crypto/attestation.py +169 -0
  46. cryptnox_id_cli/crypto/csr.py +135 -0
  47. cryptnox_id_cli/crypto/piv_objects.py +79 -0
  48. cryptnox_id_cli/crypto/x509util.py +37 -0
  49. cryptnox_id_cli/output/__init__.py +5 -0
  50. cryptnox_id_cli/output/render.py +106 -0
  51. cryptnox_id_cli/secrets/__init__.py +5 -0
  52. cryptnox_id_cli/secrets/redaction.py +141 -0
  53. cryptnox_id_cli/secrets/resolver.py +93 -0
  54. cryptnox_id_cli/state/__init__.py +19 -0
  55. cryptnox_id_cli/state/detector.py +273 -0
  56. cryptnox_id_cli/state/model.py +147 -0
  57. cryptnox_id_cli/transport/__init__.py +28 -0
  58. cryptnox_id_cli/transport/apdu.py +72 -0
  59. cryptnox_id_cli/transport/elevation.py +165 -0
  60. cryptnox_id_cli/transport/errors.py +185 -0
  61. cryptnox_id_cli/transport/pcsc.py +351 -0
  62. cryptnox_id_cli/transport/scp02.py +247 -0
  63. cryptnox_id_cli/transport/scp03.py +213 -0
  64. cryptnox_id_cli/trust/__init__.py +90 -0
  65. cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
  66. cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
  67. cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
  68. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
  69. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
  70. cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
  71. cryptnox_id_cli/util/__init__.py +1 -0
  72. cryptnox_id_cli/util/hexutil.py +36 -0
  73. cryptnox_id_cli/util/tlv.py +144 -0
  74. cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
  75. cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
  76. cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
  77. cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
  78. cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
  79. cryptnox_id_cli-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2558 @@
1
+ """The ``piv`` command — PIV management and personalization.
2
+
3
+ Read-only inspection (info/status/slots/certs/objects), PIN operations, the SCP03
4
+ admin channel, and the ``perso`` namespace (PIN/PUK values, on-card key generation,
5
+ CSR / certificates, data objects, validate/smoke-test). Factory pre-personalization
6
+ lives under ``factory piv preperso``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from rich.console import Console
18
+
19
+ from cryptnox_id_cli import CLI_NAME, trust
20
+ from cryptnox_id_cli.applets.piv import constants as pivc
21
+ from cryptnox_id_cli.applets.piv import keyimport
22
+ from cryptnox_id_cli.applets.piv import perso as perso_mod
23
+ from cryptnox_id_cli.applets.piv import preperso as preperso_mod
24
+ from cryptnox_id_cli.applets.piv import profiles as prof_mod
25
+ from cryptnox_id_cli.applets.piv.admin import PivAdmin, scp_label
26
+ from cryptnox_id_cli.applets.piv.objects import (
27
+ PIV_OBJECTS,
28
+ extract_certificate,
29
+ object_by_name,
30
+ wrap_certificate,
31
+ )
32
+ from cryptnox_id_cli.applets.piv.piv import PivApplet
33
+ from cryptnox_id_cli.applets.piv.slots import PIV_SLOTS
34
+ from cryptnox_id_cli.cli.context import AppContext
35
+ from cryptnox_id_cli.crypto import csr as csr_mod
36
+ from cryptnox_id_cli.crypto import piv_objects, x509util
37
+ from cryptnox_id_cli.crypto.attestation import verify_attestation_chain
38
+ from cryptnox_id_cli.output.render import state_style
39
+ from cryptnox_id_cli.secrets.resolver import resolve_scp03_keys, resolve_secret
40
+ from cryptnox_id_cli.state import StateDetector
41
+ from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError, describe_sw
42
+ from cryptnox_id_cli.util import tlv
43
+ from cryptnox_id_cli.util.hexutil import to_hex
44
+
45
+ # Slots that carry a certificate container, and the object that holds it.
46
+ SLOT_CERT_OBJECT = {
47
+ 0x9A: "auth-cert",
48
+ 0x9C: "sign-cert",
49
+ 0x9D: "keymgmt-cert",
50
+ 0x9E: "card-auth-cert",
51
+ }
52
+
53
+
54
+ @click.group("piv")
55
+ def command() -> None:
56
+ """Manage the PIV applet (status, PIN, certs, objects, personalization)."""
57
+
58
+
59
+ def _select(session: object) -> PivApplet:
60
+ piv = PivApplet(session) # type: ignore[arg-type]
61
+ piv.select()
62
+ return piv
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ @command.command("info")
67
+ @click.pass_obj
68
+ def info(app: AppContext) -> None:
69
+ """Show the PIV AID, label, URL and the algorithms/slots this applet exposes."""
70
+ with app.open_session() as session:
71
+ piv = _select(session)
72
+ apt = piv.apt
73
+ if apt is None: # unreachable: _select() always parses the APT
74
+ raise RuntimeError("card returned no PIV Application Property Template")
75
+ supported = [pivc.ALGORITHMS[a] for a in sorted(pivc.SUPPORTED_ALGORITHMS)]
76
+ note = "Values are read from the card; no FIPS/NIST/SP800-73 validation is claimed."
77
+ payload = {
78
+ "aid": apt.aid_hex,
79
+ "label": apt.label,
80
+ "url": apt.url,
81
+ "supported_algorithms": supported,
82
+ "slots": [{"ref": s.ref_hex, "name": s.name} for s in PIV_SLOTS],
83
+ "note": note,
84
+ }
85
+
86
+ def human(c: Console) -> None:
87
+ c.print("[bold]PIV applet[/bold]")
88
+ c.print(f" AID: {apt.aid_hex}")
89
+ c.print(f" Label: {apt.label}")
90
+ c.print(f" URL: {apt.url}")
91
+ c.print(f"\n Supported algorithms: {', '.join(supported)}")
92
+ c.print(f" Key slots: {', '.join(s.ref_hex for s in PIV_SLOTS)}")
93
+ c.print(f"\n[dim]{note}[/dim]")
94
+
95
+ app.out.result(payload, human)
96
+
97
+
98
+ @command.command("status")
99
+ @click.pass_obj
100
+ def status(app: AppContext) -> None:
101
+ """Show PIV lifecycle state, PIN/PUK status and which objects are present."""
102
+ with app.open_session() as session:
103
+ st = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
104
+ payload: dict[str, object] = {
105
+ "state": st.piv.label,
106
+ "reader": app.resolved_reader,
107
+ "apt": st.piv_apt.to_dict() if st.piv_apt else None,
108
+ "pin": st.piv_pin.to_dict() if st.piv_pin else None,
109
+ "puk": st.piv_puk.to_dict() if st.piv_puk else None,
110
+ "objects_present": st.piv_objects,
111
+ "notes": st.notes,
112
+ }
113
+
114
+ def human(c: Console) -> None:
115
+ c.print(f"[bold]PIV state:[/bold] {state_style(st.piv.label)}")
116
+ if st.piv_pin:
117
+ extra = f", {st.piv_pin.retries} tries left" if st.piv_pin.retries is not None else ""
118
+ blocked = " [red](blocked)[/red]" if st.piv_pin.blocked else ""
119
+ c.print(f" PIN (80): {'set' if st.piv_pin.configured else 'not set'}{extra}{blocked}")
120
+ if st.piv_puk:
121
+ extra = f", {st.piv_puk.retries} tries left" if st.piv_puk.retries is not None else ""
122
+ blocked = " [red](blocked)[/red]" if st.piv_puk.blocked else ""
123
+ c.print(f" PUK (81): {'set' if st.piv_puk.configured else 'not set'}{extra}{blocked}")
124
+ if st.piv_objects:
125
+ table = app.out.table("Object", "Present")
126
+ for name, present in st.piv_objects.items():
127
+ table.add_row(name, "[green]yes[/green]" if present else "[dim]no[/dim]")
128
+ c.print(table)
129
+ for note in st.notes:
130
+ c.print(f"[yellow]*[/yellow] {note}")
131
+
132
+ app.out.result(payload, human)
133
+
134
+
135
+ @command.command("discover")
136
+ @click.pass_obj
137
+ def discover(app: AppContext) -> None:
138
+ """Read the PIV Discovery Object (PIN usage policy), if present."""
139
+ with app.open_session() as session:
140
+ piv = _select(session)
141
+ data = piv.read_object(bytes([0x7E]))
142
+ if data is None:
143
+ app.out.result(
144
+ {"present": False},
145
+ lambda c: c.print("Discovery object: [yellow]not present[/yellow]"),
146
+ )
147
+ return
148
+ nodes = tlv.parse(data)
149
+ disc = tlv.find(nodes, 0x7E)
150
+ scope = disc.children if (disc and disc.children) else nodes
151
+ aid_node = tlv.find(scope, 0x4F)
152
+ policy_node = tlv.find(scope, 0x5F2F)
153
+ payload = {
154
+ "present": True,
155
+ "aid": to_hex(aid_node.value) if aid_node else None,
156
+ "pin_policy": to_hex(policy_node.value) if policy_node else None,
157
+ }
158
+
159
+ def human(c: Console) -> None:
160
+ c.print("Discovery object: [green]present[/green]")
161
+ c.print(f" AID: {payload['aid']}")
162
+ c.print(f" PIN usage policy: {payload['pin_policy']}")
163
+
164
+ app.out.result(payload, human)
165
+
166
+
167
+ @command.command("slots")
168
+ @click.pass_obj
169
+ def slots(app: AppContext) -> None:
170
+ """List PIV key slots and whether each cert-bearing slot has a certificate."""
171
+ with app.open_session() as session:
172
+ piv = _select(session)
173
+ cert_present = {}
174
+ for ref, obj_name in SLOT_CERT_OBJECT.items():
175
+ obj = object_by_name(obj_name)
176
+ cert_present[ref] = piv.object_present(obj.oid) if obj else False
177
+ rows = []
178
+ for s in PIV_SLOTS:
179
+ has = None if s.ref not in SLOT_CERT_OBJECT else bool(cert_present.get(s.ref))
180
+ rows.append({"ref": s.ref_hex, "name": s.name, "usage": s.usage, "certificate": has})
181
+
182
+ def human(c: Console) -> None:
183
+ table = app.out.table("Slot", "Name", "Usage", "Cert")
184
+ for r in rows:
185
+ cert = (
186
+ "-"
187
+ if r["certificate"] is None
188
+ else ("[green]yes[/green]" if r["certificate"] else "[dim]no[/dim]")
189
+ )
190
+ table.add_row(str(r["ref"]), str(r["name"]), str(r["usage"]), cert)
191
+ c.print(table)
192
+
193
+ app.out.result({"slots": rows}, human)
194
+
195
+
196
+ # ------------------------------------------------------------------ certs --- #
197
+ @command.group("certs")
198
+ def certs() -> None:
199
+ """Inspect and export PIV certificates."""
200
+
201
+
202
+ def _read_cert_der(piv: PivApplet, obj_name: str) -> bytes | None:
203
+ obj = object_by_name(obj_name)
204
+ if obj is None:
205
+ return None
206
+ val = piv.read_object(obj.oid)
207
+ return extract_certificate(val) if val is not None else None
208
+
209
+
210
+ @certs.command("list")
211
+ @click.pass_obj
212
+ def certs_list(app: AppContext) -> None:
213
+ """List certificate slots and summarise any certificate found."""
214
+ with app.open_session() as session:
215
+ piv = _select(session)
216
+ out = []
217
+ for ref, obj_name in SLOT_CERT_OBJECT.items():
218
+ der = _read_cert_der(piv, obj_name)
219
+ entry: dict[str, object] = {
220
+ "slot": f"{ref:02X}",
221
+ "object": obj_name,
222
+ "present": der is not None,
223
+ }
224
+ if der is not None:
225
+ try:
226
+ entry["certificate"] = x509util.describe_certificate(der)
227
+ except Exception as exc: # noqa: BLE001 - report parse failure, don't crash
228
+ entry["error"] = f"parse failed: {exc}"
229
+ out.append(entry)
230
+
231
+ def human(c: Console) -> None:
232
+ table = app.out.table("Slot", "Object", "Present", "Subject", "Expires")
233
+ for e in out:
234
+ raw = e.get("certificate")
235
+ cert = raw if isinstance(raw, dict) else {}
236
+ table.add_row(
237
+ str(e["slot"]),
238
+ str(e["object"]),
239
+ "[green]yes[/green]" if e["present"] else "[dim]no[/dim]",
240
+ str(cert.get("subject", "-")),
241
+ str(cert.get("not_after", "-")),
242
+ )
243
+ c.print(table)
244
+
245
+ app.out.result({"certificates": out}, human)
246
+
247
+
248
+ @certs.command("export")
249
+ @click.option("--slot", required=True, help="Slot hex, e.g. 9A.")
250
+ @click.option("--out", "out_", required=True, type=click.Path(dir_okay=False))
251
+ @click.option("--der", is_flag=True, help="Write DER instead of PEM.")
252
+ @click.pass_obj
253
+ def certs_export(app: AppContext, slot: str, out_: str, der: bool) -> None:
254
+ """Export a slot's certificate (PEM by default). Certificates are not secret."""
255
+ ref = int(slot, 16)
256
+ obj_name = SLOT_CERT_OBJECT.get(ref)
257
+ if obj_name is None:
258
+ raise click.BadParameter(f"slot {slot} has no certificate container")
259
+ with app.open_session() as session:
260
+ cert_der = _read_cert_der(_select(session), obj_name)
261
+ if cert_der is None:
262
+ app.out.result(
263
+ {"present": False, "slot": slot},
264
+ lambda c: c.print(f"[yellow]No certificate in slot {slot}.[/yellow]"),
265
+ )
266
+ return
267
+ data = cert_der if der else x509util.to_pem(cert_der)
268
+ Path(out_).write_bytes(data)
269
+ fmt = "DER" if der else "PEM"
270
+ app.out.result(
271
+ {"present": True, "slot": slot, "out": out_, "format": fmt},
272
+ lambda c: c.print(f"[green]Wrote {fmt} certificate to {out_}[/green]"),
273
+ )
274
+
275
+
276
+ @certs.command("inspect")
277
+ @click.option("--slot", required=True, help="Slot hex, e.g. 9A.")
278
+ @click.pass_obj
279
+ def certs_inspect(app: AppContext, slot: str) -> None:
280
+ """Show certificate details for a slot."""
281
+ ref = int(slot, 16)
282
+ obj_name = SLOT_CERT_OBJECT.get(ref)
283
+ if obj_name is None:
284
+ raise click.BadParameter(f"slot {slot} has no certificate container")
285
+ with app.open_session() as session:
286
+ cert_der = _read_cert_der(_select(session), obj_name)
287
+ if cert_der is None:
288
+ app.out.result(
289
+ {"present": False, "slot": slot},
290
+ lambda c: c.print(f"[yellow]No certificate in slot {slot}.[/yellow]"),
291
+ )
292
+ return
293
+ desc = x509util.describe_certificate(cert_der)
294
+
295
+ def human(c: Console) -> None:
296
+ for key, value in desc.items():
297
+ c.print(f" {key}: {value}")
298
+
299
+ app.out.result({"present": True, "slot": slot, "certificate": desc}, human)
300
+
301
+
302
+ # ------------------------------------------------------------ attestation --- #
303
+ # The factory/issuance-time PIV key-attestation leaf lives in the retired-slot-95
304
+ # certificate container (5FC120). Only slot 9C is attested in v1.
305
+ ATTESTATION_CERT_OBJECT = {0x9C: "attestation-cert"}
306
+
307
+
308
+ def _csr_spki(path: str) -> bytes:
309
+ """SubjectPublicKeyInfo (DER) of the public key in a PEM or DER CSR."""
310
+ from cryptography import x509
311
+ from cryptography.hazmat.primitives import serialization
312
+
313
+ raw = Path(path).read_bytes()
314
+ try:
315
+ req = x509.load_pem_x509_csr(raw)
316
+ except ValueError:
317
+ req = x509.load_der_x509_csr(raw)
318
+ return req.public_key().public_bytes(
319
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
320
+ )
321
+
322
+
323
+ @command.command("export-attestation")
324
+ @click.option("--slot", default="9C", show_default=True, help="Attested slot (9C in v1).")
325
+ @click.option("--out", "out_", type=click.Path(dir_okay=False), help="Write the chain to FILE.")
326
+ @click.option("--der", is_flag=True, help="Write the leaf as DER (no chain) instead of PEM.")
327
+ @click.pass_obj
328
+ def export_attestation(app: AppContext, slot: str, out_: str | None, der: bool) -> None:
329
+ """Export the on-card key-attestation leaf (PEM chain incl. bundled intermediates)."""
330
+ ref = int(slot, 16)
331
+ obj_name = ATTESTATION_CERT_OBJECT.get(ref)
332
+ if obj_name is None:
333
+ raise click.BadParameter(
334
+ f"slot {slot} carries no attestation certificate", param_hint="--slot"
335
+ )
336
+ with app.open_session() as session:
337
+ leaf_der = _read_cert_der(_select(session), obj_name)
338
+ if leaf_der is None:
339
+ app.out.result(
340
+ {"present": False, "slot": slot},
341
+ lambda c: c.print(f"[yellow]No attestation certificate in slot {slot}.[/yellow]"),
342
+ )
343
+ return
344
+ inter_ders: list[bytes] = []
345
+ if der:
346
+ data = leaf_der
347
+ else:
348
+ # Export the leaf's own chain, not the whole trust store: the bundled pool holds
349
+ # sibling CAs that have nothing to do with this leaf. path_ders is [leaf, ...issuers].
350
+ roots, pool = trust.load_anchors()
351
+ path = verify_attestation_chain(leaf_der, pool, roots).path_ders
352
+ # path is [leaf, ...issuers, anchor]; drop the leaf and the pinned root — a relying
353
+ # party already holds the root, and shipping it invites trusting it from the file.
354
+ inter_ders = [d for d in path[1:] if d not in set(roots)]
355
+ data = b"".join([x509util.to_pem(leaf_der), *(x509util.to_pem(d) for d in inter_ders)])
356
+ fmt = "DER" if der else "PEM"
357
+ if out_:
358
+ Path(out_).write_bytes(data)
359
+ payload = {
360
+ "present": True,
361
+ "slot": slot,
362
+ "format": fmt,
363
+ "out": out_,
364
+ "intermediates_bundled": len(inter_ders),
365
+ }
366
+
367
+ def human(c: Console) -> None:
368
+ if out_:
369
+ extra = f" (+{len(inter_ders)} intermediate)" if inter_ders else ""
370
+ c.print(f"[green]Wrote {fmt} attestation to {out_}{extra}[/green]")
371
+ else:
372
+ c.print(to_hex(data) if der else data.decode())
373
+
374
+ app.out.result(payload, human)
375
+
376
+
377
+ @command.command("verify-attestation")
378
+ @click.option("--slot", default="9C", show_default=True, help="Attested slot (9C in v1).")
379
+ @click.option(
380
+ "--csr",
381
+ "csr_file",
382
+ type=click.Path(exists=True, dir_okay=False),
383
+ help="Also check the attested key matches this CSR's public key.",
384
+ )
385
+ @click.pass_obj
386
+ def verify_attestation(app: AppContext, slot: str, csr_file: str | None) -> None:
387
+ """Validate the on-card attestation chain against the pinned Cryptnox root."""
388
+ ref = int(slot, 16)
389
+ obj_name = ATTESTATION_CERT_OBJECT.get(ref)
390
+ if obj_name is None:
391
+ raise click.BadParameter(
392
+ f"slot {slot} carries no attestation certificate", param_hint="--slot"
393
+ )
394
+ csr_spki = _csr_spki(csr_file) if csr_file else None
395
+ roots, inter_ders = trust.load_anchors()
396
+ with app.open_session() as session:
397
+ leaf_der = _read_cert_der(_select(session), obj_name)
398
+ if leaf_der is None:
399
+ app.out.result(
400
+ {"present": False, "slot": slot},
401
+ lambda c: c.print(f"[yellow]No attestation certificate in slot {slot}.[/yellow]"),
402
+ )
403
+ return
404
+ result = verify_attestation_chain(leaf_der, inter_ders, roots, csr_spki=csr_spki)
405
+ leaf_desc = x509util.describe_certificate(leaf_der)
406
+ payload = {"present": True, "slot": slot, "leaf": leaf_desc, **result.to_dict()}
407
+
408
+ def human(c: Console) -> None:
409
+ verdict = "[green]verified[/green]" if result.verified else "[red]NOT verified[/red]"
410
+ c.print(f"Attestation (slot {slot}): {verdict}")
411
+ c.print(f" subject: {leaf_desc['subject']}")
412
+ if result.chain:
413
+ c.print(f" chain: {' -> '.join(result.chain)}")
414
+ if result.csr_match is not None:
415
+ mark = "[green]yes[/green]" if result.csr_match else "[red]no[/red]"
416
+ c.print(f" CSR key match: {mark}")
417
+ for reason in result.reasons:
418
+ c.print(f" [yellow]-[/yellow] {reason}")
419
+
420
+ app.out.result(payload, human)
421
+
422
+
423
+ # ---------------------------------------------------------------- objects --- #
424
+ @command.group("objects")
425
+ def objects() -> None:
426
+ """List and read PIV data objects."""
427
+
428
+
429
+ @objects.command("list")
430
+ @click.pass_obj
431
+ def objects_list(app: AppContext) -> None:
432
+ """List PIV data objects and whether each is present."""
433
+ with app.open_session() as session:
434
+ piv = _select(session)
435
+ rows = [
436
+ {
437
+ "name": o.name,
438
+ "oid": o.oid_hex,
439
+ "present": piv.object_present(o.oid),
440
+ "mandatory": o.mandatory,
441
+ "pin_protected": o.pin_protected,
442
+ }
443
+ for o in PIV_OBJECTS
444
+ ]
445
+
446
+ def human(c: Console) -> None:
447
+ table = app.out.table("Object", "OID", "Present", "Mandatory", "PIN")
448
+ for r in rows:
449
+ table.add_row(
450
+ str(r["name"]),
451
+ str(r["oid"]),
452
+ "[green]yes[/green]" if r["present"] else "[dim]no[/dim]",
453
+ "yes" if r["mandatory"] else "-",
454
+ "yes" if r["pin_protected"] else "-",
455
+ )
456
+ c.print(table)
457
+
458
+ app.out.result({"objects": rows}, human)
459
+
460
+
461
+ @contextlib.contextmanager
462
+ def _transcript_hold(session):
463
+ """Withhold the session's APDU transcript lines, then emit them re-masked.
464
+
465
+ Lets the caller register a response payload with ``session.redactor`` after
466
+ receiving it but before the lines reach the --apdu-log file or the --verbose
467
+ trace, so PIN-gated cardholder data keeps the transcript's "secrets redacted"
468
+ promise. (A first-class hook on CardSession would be the durable home for
469
+ this; until then the emit seam is intercepted here.)
470
+ """
471
+ held: list[str] = []
472
+ original = session._emit
473
+ session._emit = held.append
474
+ try:
475
+ yield
476
+ finally:
477
+ session._emit = original
478
+ for line in held:
479
+ original(session.redactor.mask(line))
480
+
481
+
482
+ @objects.command("read")
483
+ @click.option("--object", "object_name", required=True, help="Object name, e.g. chuid.")
484
+ @click.option("--out", "out_", type=click.Path(dir_okay=False), help="Write raw bytes to FILE.")
485
+ @click.pass_obj
486
+ def objects_read(app: AppContext, object_name: str, out_: str | None) -> None:
487
+ """Read a PIV data object (prints hex, or writes raw bytes with --out).
488
+
489
+ PIN-gated objects (printed, fingerprints, facial) need the PIN verified
490
+ first — without it, GET DATA returns 6982 and the object reads back as
491
+ absent even when populated. Only prompts/consumes a PIN for those objects.
492
+ """
493
+ obj = object_by_name(object_name)
494
+ if obj is None:
495
+ names = ", ".join(o.name for o in PIV_OBJECTS)
496
+ raise click.BadParameter(f"unknown object {object_name!r}. Known: {names}")
497
+ with app.open_session() as session:
498
+ piv = _select(session)
499
+ if obj.pin_protected:
500
+ secret = resolve_secret(
501
+ redactor=app.redactor,
502
+ env_var="CRYPTNOX_PIV_PIN",
503
+ prompt_label="PIV PIN",
504
+ )
505
+ resp = piv.verify_pin(secret)
506
+ _check_pin_verify(resp)
507
+ # PIN-gated content (printed, fingerprints, facial) is cardholder data
508
+ # the applet itself protects; it must not land in the "secrets redacted"
509
+ # APDU transcript. Hold the GET DATA lines, register the payload, then
510
+ # release them masked. Stdout still shows the content - the user asked
511
+ # for it; the promise being kept is about --apdu-log/--verbose.
512
+ with _transcript_hold(session):
513
+ val = piv.read_object(obj.oid)
514
+ shown = None if val is None else val.hex().upper()
515
+ session.redactor.register(val)
516
+ else:
517
+ val = piv.read_object(obj.oid)
518
+ shown = None if val is None else app.redactor.mask(val.hex().upper())
519
+ if val is None:
520
+ app.out.result(
521
+ {"name": obj.name, "present": False},
522
+ lambda c: c.print(f"Object {obj.name}: [yellow]not present[/yellow]"),
523
+ )
524
+ return
525
+ if out_:
526
+ Path(out_).write_bytes(val)
527
+ payload = {
528
+ "name": obj.name,
529
+ "present": True,
530
+ "length": len(val),
531
+ "hex": None if out_ else shown,
532
+ "out": out_,
533
+ }
534
+
535
+ def human(c: Console) -> None:
536
+ if out_:
537
+ c.print(f"[green]Wrote {len(val)} bytes to {out_}[/green]")
538
+ else:
539
+ c.print(f"{obj.name} ({len(val)} bytes): {payload['hex']}")
540
+
541
+ app.out.result(payload, human)
542
+
543
+
544
+ # -------------------------------------------------------------------- pin --- #
545
+ @command.group("pin")
546
+ def pin() -> None:
547
+ """PIN/PUK status and verification."""
548
+
549
+
550
+ @pin.command("status")
551
+ @click.pass_obj
552
+ def pin_status(app: AppContext) -> None:
553
+ """Show PIN and PUK status (non-decrementing query)."""
554
+ with app.open_session() as session:
555
+ piv = _select(session)
556
+ pin_st = piv.pin_status(pivc.REF_PIV_PIN)
557
+ puk_st = piv.pin_status(pivc.REF_PUK)
558
+
559
+ def human(c: Console) -> None:
560
+ for label, st in (("PIN (80)", pin_st), ("PUK (81)", puk_st)):
561
+ if not st.configured:
562
+ c.print(f" {label}: [dim]not configured[/dim]")
563
+ elif st.blocked:
564
+ c.print(f" {label}: [red]blocked[/red]")
565
+ else:
566
+ tries = f"{st.retries} tries left" if st.retries is not None else "configured"
567
+ c.print(f" {label}: [green]{tries}[/green]")
568
+
569
+ app.out.result({"pin": pin_st.to_dict(), "puk": puk_st.to_dict()}, human)
570
+
571
+
572
+ @pin.command("verify")
573
+ @click.option("--pin", "pin_value", help="(discouraged) PIN on the CLI; prefer prompt/env.")
574
+ @click.pass_obj
575
+ def pin_verify(app: AppContext, pin_value: str | None) -> None:
576
+ """Verify the PIV PIN. A wrong PIN consumes one retry."""
577
+ app.out.warn("a wrong PIN decrements the retry counter.")
578
+ secret = resolve_secret(
579
+ redactor=app.redactor,
580
+ env_var="CRYPTNOX_PIV_PIN",
581
+ prompt_label="PIV PIN",
582
+ provided=pin_value,
583
+ )
584
+ with app.open_session() as session:
585
+ resp = _select(session).verify_pin(secret)
586
+ info = describe_sw(resp.sw1, resp.sw2)
587
+ payload: dict[str, object] = {"verified": resp.ok, "sw": info.sw_hex(), "message": info.message}
588
+ if not resp.ok and info.retries is not None:
589
+ payload["retries_remaining"] = info.retries
590
+
591
+ def human(c: Console) -> None:
592
+ if resp.ok:
593
+ c.print("[green]PIN verified.[/green]")
594
+ else:
595
+ c.print(f"[red]PIN not verified:[/red] {info.message} (SW={info.sw_hex()})")
596
+
597
+ app.out.result(payload, human)
598
+
599
+
600
+ def _change_secret_result(
601
+ app: AppContext, label: str, resp, *, blocked_hint: str | None = None
602
+ ) -> None:
603
+ info = describe_sw(resp.sw1, resp.sw2)
604
+ payload: dict[str, object] = {"changed": resp.ok, "sw": info.sw_hex(), "message": info.message}
605
+ blocked = not resp.ok and (resp.sw == 0x6983 or info.retries == 0)
606
+ if not resp.ok and info.retries is not None:
607
+ payload["retries_remaining"] = info.retries
608
+ if blocked:
609
+ payload["blocked"] = True
610
+ if blocked_hint:
611
+ payload["recovery"] = blocked_hint
612
+
613
+ def human(c: Console) -> None:
614
+ if resp.ok:
615
+ c.print(f"[green]{label} changed.[/green]")
616
+ else:
617
+ c.print(f"[red]{label} not changed:[/red] {info.message} (SW={info.sw_hex()})")
618
+ if blocked and blocked_hint:
619
+ c.print(f" [red]{blocked_hint}[/red]")
620
+
621
+ app.out.result(payload, human)
622
+
623
+
624
+ @pin.command("change")
625
+ @click.option("--old-pin", help="(discouraged) current PIN on the CLI; prefer prompt/env.")
626
+ @click.option("--new-pin", help="(discouraged) new PIN on the CLI; prefer prompt/env.")
627
+ @click.pass_obj
628
+ def pin_change(app: AppContext, old_pin: str | None, new_pin: str | None) -> None:
629
+ """Change the PIV PIN (cardholder). A wrong current PIN consumes one retry."""
630
+ app.out.warn("a wrong current PIN decrements the retry counter.")
631
+ old = resolve_secret(
632
+ redactor=app.redactor,
633
+ env_var="CRYPTNOX_PIV_PIN",
634
+ prompt_label="Current PIV PIN",
635
+ provided=old_pin,
636
+ )
637
+ new = resolve_secret(
638
+ redactor=app.redactor,
639
+ env_var="CRYPTNOX_PIV_NEW_PIN",
640
+ prompt_label="New PIV PIN",
641
+ provided=new_pin,
642
+ )
643
+ with app.open_session() as session:
644
+ resp = _select(session).change_reference(old, new, pivc.REF_PIV_PIN)
645
+ _change_secret_result(
646
+ app,
647
+ "PIN",
648
+ resp,
649
+ blocked_hint="PIN is now blocked. Recover it with `piv pin unblock` (needs the PUK).",
650
+ )
651
+
652
+
653
+ @pin.command("unblock")
654
+ @click.option("--puk", "puk_value", help="(discouraged) PUK on the CLI; prefer prompt/env.")
655
+ @click.option("--new-pin", help="(discouraged) new PIN on the CLI; prefer prompt/env.")
656
+ @click.pass_obj
657
+ def pin_unblock(app: AppContext, puk_value: str | None, new_pin: str | None) -> None:
658
+ """Unblock a blocked PIV PIN via the PUK (RESET RETRY COUNTER, INS 2C)."""
659
+ app.out.warn("a wrong PUK decrements the PUK counter; a blocked PUK is unrecoverable.")
660
+ puk = resolve_secret(
661
+ redactor=app.redactor,
662
+ env_var="CRYPTNOX_PIV_PUK",
663
+ prompt_label="PIV PUK",
664
+ provided=puk_value,
665
+ )
666
+ new = resolve_secret(
667
+ redactor=app.redactor,
668
+ env_var="CRYPTNOX_PIV_NEW_PIN",
669
+ prompt_label="New PIV PIN",
670
+ provided=new_pin,
671
+ )
672
+ with app.open_session() as session:
673
+ resp = _select(session).unblock_pin(puk, new, pivc.REF_PIV_PIN)
674
+ _change_secret_result(
675
+ app,
676
+ "PIN",
677
+ resp,
678
+ blocked_hint="the PUK just used is now blocked - there is no recovery path.",
679
+ )
680
+
681
+
682
+ @command.group("puk")
683
+ def puk() -> None:
684
+ """PUK operations (the unblocking secret for the PIN)."""
685
+
686
+
687
+ @puk.command("change")
688
+ @click.option("--old-puk", help="(discouraged) current PUK on the CLI; prefer prompt/env.")
689
+ @click.option("--new-puk", help="(discouraged) new PUK on the CLI; prefer prompt/env.")
690
+ @click.pass_obj
691
+ def puk_change(app: AppContext, old_puk: str | None, new_puk: str | None) -> None:
692
+ """Change the PIV PUK (cardholder). A wrong current PUK consumes one retry."""
693
+ app.out.warn("a wrong current PUK decrements the retry counter.")
694
+ old = resolve_secret(
695
+ redactor=app.redactor,
696
+ env_var="CRYPTNOX_PIV_PUK",
697
+ prompt_label="Current PIV PUK",
698
+ provided=old_puk,
699
+ )
700
+ new = resolve_secret(
701
+ redactor=app.redactor,
702
+ env_var="CRYPTNOX_PIV_NEW_PUK",
703
+ prompt_label="New PIV PUK",
704
+ provided=new_puk,
705
+ )
706
+ with app.open_session() as session:
707
+ resp = _select(session).change_reference(old, new, pivc.REF_PUK)
708
+ _change_secret_result(
709
+ app, "PUK", resp, blocked_hint="PUK is now blocked - there is no recovery path."
710
+ )
711
+
712
+
713
+ # ------------------------------------------------------------------ admin --- #
714
+ @command.group("admin")
715
+ def admin() -> None:
716
+ """PIV administrative operations over SCP03 (vendor: OpenFIPS201)."""
717
+
718
+
719
+ @admin.command("status")
720
+ @click.option("--key-version", default=0, type=int, help="SCP03 key version (default 0).")
721
+ @click.pass_obj
722
+ def admin_status(app: AppContext, key_version: int) -> None:
723
+ """Probe the SCP03 admin channel (read-only INITIALIZE UPDATE, no auth)."""
724
+ with app.open_session() as session:
725
+ adm = PivAdmin(session)
726
+ adm.select()
727
+ info = adm.initialize_update_probe(key_version=key_version)
728
+
729
+ def human(c: Console) -> None:
730
+ scp_id = info.get("scp_id")
731
+ label = scp_label(scp_id)
732
+ c.print(f"PIV admin secure channel ({label}):")
733
+ if isinstance(scp_id, int):
734
+ c.print(f" SCP ID: {scp_id:#04x} ({label})")
735
+ c.print(f" key version: {info.get('key_version')}")
736
+ i_param = info.get("scp_i")
737
+ if isinstance(i_param, int):
738
+ c.print(f" i-param: {i_param:#04x}")
739
+ c.print(" INITIALIZE UPDATE supported - run `piv admin authenticate` to open it.")
740
+
741
+ app.out.result(info, human)
742
+
743
+
744
+ @admin.command("authenticate")
745
+ @click.option(
746
+ "--default-keys",
747
+ is_flag=True,
748
+ help="Use the default GlobalPlatform TEST keys "
749
+ "(publicly known - fine for dev/eval, never for deployment).",
750
+ )
751
+ @click.option("--key-version", default=0, type=int, help="SCP03 key version (default 0).")
752
+ @click.pass_obj
753
+ def admin_authenticate(app: AppContext, default_keys: bool, key_version: int) -> None:
754
+ """Open the PIV admin channel (mutual auth, auto-detecting SCP02/SCP03) and self-test."""
755
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
756
+ security_level = 0x03 # C-MAC + C-ENC
757
+ with app.open_session() as session:
758
+ adm = PivAdmin(session)
759
+ adm.select()
760
+ adm.open(keys, key_version=key_version, security_level=security_level)
761
+ scp_ver_label = scp_label(adm.scp_version)
762
+ resp = adm.self_test_read()
763
+ self_ok = resp.ok or resp.sw == 0x6A82
764
+ payload: dict[str, object] = {
765
+ "authenticated": True,
766
+ "scp_version": scp_ver_label,
767
+ "security_level": f"{security_level:#04x}",
768
+ "self_test_sw": resp.sw_hex(),
769
+ "self_test_ok": self_ok,
770
+ }
771
+
772
+ def human(c: Console) -> None:
773
+ c.print(
774
+ f"[green]{scp_ver_label} secure channel established[/green] (mutual authentication OK)."
775
+ )
776
+ c.print(f" Security level: {security_level:#04x} (C-MAC + C-ENC)")
777
+ verdict = "OK" if self_ok else "unexpected"
778
+ c.print(f" Admin round-trip (wrapped GET DATA): SW={resp.sw_hex()} -> {verdict}")
779
+
780
+ app.out.result(payload, human)
781
+
782
+
783
+ # ------------------------------------------------------ legacy redirect --- #
784
+ @command.command(
785
+ "preperso",
786
+ hidden=True,
787
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
788
+ )
789
+ @click.argument("args", nargs=-1, type=click.UNPROCESSED)
790
+ @click.pass_obj
791
+ def preperso_redirect(app: AppContext, args: tuple[str, ...]) -> None:
792
+ """(moved) Pre-personalization is MANUFACTURING-ONLY; see `factory piv preperso`."""
793
+ raise CryptnoxError(
794
+ "Pre-personalization is a MANUFACTURING-ONLY operation and now lives under "
795
+ f"`{CLI_NAME} factory piv preperso`. Re-run it there."
796
+ )
797
+
798
+
799
+ # ------------------------------------------------------------------ perso --- #
800
+ @command.group("perso")
801
+ def perso() -> None:
802
+ """PIV personalization: set PIN/PUK values, generate keys, write objects."""
803
+
804
+
805
+ @perso.command("generate-key")
806
+ @click.option("--slot", required=True, help="Key slot hex, e.g. 9A.")
807
+ @click.option(
808
+ "--algorithm", default="ECCP256", show_default=True, help="ECCP256/ECCP384/RSA2048/RSA3072."
809
+ )
810
+ @click.option(
811
+ "--default-keys",
812
+ is_flag=True,
813
+ help="Use the default GlobalPlatform TEST keys "
814
+ "(publicly known - fine for dev/eval, never for deployment).",
815
+ )
816
+ @click.option(
817
+ "--out", "out_", type=click.Path(dir_okay=False), help="Write public-key PEM to FILE."
818
+ )
819
+ @click.pass_obj
820
+ def perso_generate_key(
821
+ app: AppContext, slot: str, algorithm: str, default_keys: bool, out_: str | None
822
+ ) -> None:
823
+ """Generate a key pair ON-CARD; the private key never leaves the card.
824
+
825
+ Replaces any key already in the slot IRREVERSIBLY. When the slot's certificate
826
+ container is occupied this asks for confirmation first (``--yes`` skips); a key
827
+ without a certificate cannot be detected and is replaced without a prompt.
828
+ """
829
+ import hashlib
830
+
831
+ from cryptography.hazmat.primitives import serialization
832
+
833
+ ref = int(slot, 16)
834
+ alg = algorithm.upper()
835
+ mech = perso_mod.ALGORITHMS.get(alg)
836
+ if mech is None or mech not in pivc.SUPPORTED_ALGORITHMS:
837
+ raise click.BadParameter(f"algorithm {algorithm!r} not supported by this applet")
838
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
839
+ with app.open_session() as session:
840
+ # Same rule quickstart applies: a certificate marks the slot as in use, so a
841
+ # certified key is never replaced silently - the old private key is destroyed
842
+ # irreversibly and every copy of that certificate goes orphan.
843
+ cert_name = SLOT_CERT_OBJECT.get(ref)
844
+ if cert_name is not None and _read_cert_der(_select(session), cert_name) is not None:
845
+ app.out.warn(
846
+ f"slot {slot} already holds a certificate; generating a new key DESTROYS "
847
+ "the current private key irreversibly and orphans that certificate."
848
+ )
849
+ if not app.yes and not click.confirm(
850
+ f"Replace the certified key in slot {slot}?", default=False
851
+ ):
852
+ raise click.Abort()
853
+ public_key = _generate_key_on_card(PivAdmin(session), keys, ref, mech, label=slot)
854
+ pem = perso_mod.public_key_pem(public_key)
855
+ der = public_key.public_bytes(
856
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
857
+ )
858
+ fingerprint = hashlib.sha256(der).hexdigest().upper()
859
+ if out_:
860
+ Path(out_).write_bytes(pem)
861
+ payload = {
862
+ "slot": slot,
863
+ "algorithm": alg,
864
+ "public_key_sha256": fingerprint,
865
+ "out": out_,
866
+ "public_key_pem": None if out_ else pem.decode(),
867
+ }
868
+
869
+ def human(c: Console) -> None:
870
+ c.print(f"[green]Generated {alg} key in slot {slot}[/green] (on-card).")
871
+ c.print(f" Public key SHA-256: {fingerprint}")
872
+ if out_:
873
+ c.print(f" Public key PEM -> {out_}")
874
+ else:
875
+ c.print(pem.decode().rstrip())
876
+
877
+ app.out.result(payload, human)
878
+
879
+
880
+ # ------------------------------------------------- session-level helpers --- #
881
+ # Each opens a FRESH SCP03 session (JCOP 4.5 allows one application APDU per
882
+ # applet-directed secure-channel session) and performs one perso primitive.
883
+ def _set_verifier(adm: PivAdmin, keys, ref: int, padded: bytes, *, label: str):
884
+ adm.select()
885
+ adm.open(keys)
886
+ return adm.send(perso_mod.set_verifier_value_apdu(ref, padded), context=f"SET {label}")
887
+
888
+
889
+ def _generate_key_on_card(adm: PivAdmin, keys, ref: int, mech: int, *, label: str):
890
+ adm.select()
891
+ adm.open(keys)
892
+ resp = adm.send(perso_mod.generate_keypair_apdu(ref, mech), context=f"GENERATE {label}")
893
+ if not resp.ok:
894
+ raise StatusWordError(resp.sw1, resp.sw2, context=f"GENERATE key {label}")
895
+ return perso_mod.parse_public_key(mech, resp.data)
896
+
897
+
898
+ def _import_cert_der(adm: PivAdmin, keys, ref: int, cert_der: bytes, *, label: str):
899
+ obj = object_by_name(SLOT_CERT_OBJECT[ref])
900
+ if obj is None: # unreachable: SLOT_CERT_OBJECT values are built-in object names
901
+ raise RuntimeError(f"unknown PIV object for slot {ref:02X}")
902
+ adm.select()
903
+ adm.open(keys)
904
+ return adm.send_chained(
905
+ perso_mod.INS_PUT_DATA,
906
+ 0x3F,
907
+ 0xFF,
908
+ perso_mod.put_data_body(obj.oid, wrap_certificate(cert_der)),
909
+ context=f"PUT DATA cert {label}",
910
+ )
911
+
912
+
913
+ def _write_standard(adm: PivAdmin, keys, names: tuple[str, ...]) -> list[dict[str, object]]:
914
+ generators: dict[str, Callable[[], bytes]] = {
915
+ "chuid": piv_objects.generate_chuid,
916
+ "ccc": piv_objects.generate_ccc,
917
+ }
918
+ results: list[dict[str, object]] = []
919
+ for name in names:
920
+ obj = object_by_name(name)
921
+ if obj is None: # unreachable: callers pass built-in object names
922
+ raise RuntimeError(f"unknown PIV object {name!r}")
923
+ adm.select()
924
+ adm.open(keys)
925
+ resp = adm.send(
926
+ perso_mod.put_data_apdu(obj.oid, generators[name]()), context=f"PUT DATA {name}"
927
+ )
928
+ results.append(
929
+ {"object": name, "ok": resp.ok, "sw": describe_sw(resp.sw1, resp.sw2).sw_hex()}
930
+ )
931
+ return results
932
+
933
+
934
+ def _check_pin_verify(resp) -> None:
935
+ """Raise on a failed PIN verification, naming the PUK-unblock recovery path
936
+ once the PIN is actually blocked rather than a generic status word.
937
+
938
+ Only ``63C0`` (retry counter exhausted) proves a blocked PIN. ``6983`` is
939
+ reported for a blocked verifier *and* when the PIN cannot be used for the
940
+ operation at hand - e.g. a slot the profile never populated - so it must not
941
+ assert a blocked PIN outright; saying so sent people to `pin unblock` for a
942
+ card whose PIN was fine.
943
+ """
944
+ if resp.ok:
945
+ return
946
+ info = describe_sw(resp.sw1, resp.sw2)
947
+ if info.retries == 0:
948
+ raise CryptnoxError(
949
+ f"PIN is blocked - 0 tries remaining (SW={info.sw_hex()}). Recover it with "
950
+ "`piv pin unblock` (needs the PUK)."
951
+ )
952
+ if resp.sw == 0x6983:
953
+ raise CryptnoxError(
954
+ f"PIN verification refused (SW={info.sw_hex()}): either the PIN is blocked, or "
955
+ "this PIN cannot be used for this operation (for example a key slot the card's "
956
+ "profile never populated). Check `piv pin status` first - it does not consume a "
957
+ "try - and if it reports 0 tries remaining, recover with `piv pin unblock` "
958
+ "(needs the PUK)."
959
+ )
960
+ raise StatusWordError(resp.sw1, resp.sw2, context="VERIFY PIN")
961
+
962
+
963
+ def _sign_with_session(session, ref: int, mech: int, pin: bytes, build):
964
+ """Verify the PIN and run ``build(signer)`` against an already-open session."""
965
+ piv = _select(session)
966
+ verified = piv.verify_pin(pin)
967
+ _check_pin_verify(verified)
968
+
969
+ def signer(digest: bytes) -> bytes:
970
+ k = keyimport.rsa_modulus_len(mech)
971
+ challenge = keyimport.emsa_pkcs1_v15(digest, k) if k else digest
972
+ apdu = perso_mod.general_authenticate_sign_apdu(ref, mech, challenge)
973
+ if len(apdu.data) > 0xFF: # oversized body: plain-channel ISO chaining
974
+ resp = session.transmit_chained(apdu, context="GENERAL AUTHENTICATE (sign)")
975
+ else:
976
+ resp = session.transmit(apdu, context="GENERAL AUTHENTICATE (sign)")
977
+ if not resp.ok:
978
+ raise StatusWordError(resp.sw1, resp.sw2, context="GENERAL AUTHENTICATE (sign)")
979
+ return perso_mod.parse_sign_response(resp.data)
980
+
981
+ return build(signer)
982
+
983
+
984
+ # Friendly diagnoses for CHANGE REFERENCE DATA ADMIN rejections during key import.
985
+ _IMPORT_SW_HINTS = {
986
+ 0x6A88: (
987
+ "no key object with this (slot, mechanism) pair exists - the cryptnox-default "
988
+ "profile creates ECC-P256 objects only; re-run pre-perso with a profile that "
989
+ "defines it, or pass --create-key-object on a non-finalized card"
990
+ ),
991
+ 0x6982: "the key object is not IMPORTABLE (or the admin channel is not accepted)",
992
+ 0x6A80: (
993
+ "the card rejected this element - for RSA this usually means the key object's "
994
+ "CRT attribute does not match; try the other --rsa-form"
995
+ ),
996
+ 0x6700: "element too long for the transport (chaining should have engaged - report this)",
997
+ }
998
+
999
+
1000
+ def _load_private_key_file(app: AppContext, raw: bytes, password: str | None):
1001
+ """Load a private key, resolving a decryption password only if it is needed."""
1002
+ try:
1003
+ return keyimport.load_private_key(raw, password.encode() if password else None)
1004
+ except TypeError:
1005
+ # Encrypted key, no password given: env var or masked prompt.
1006
+ secret = resolve_secret(
1007
+ redactor=app.redactor,
1008
+ env_var="CRYPTNOX_PIV_KEY_PASSWORD",
1009
+ prompt_label="Key file password",
1010
+ provided=None,
1011
+ )
1012
+ try:
1013
+ return keyimport.load_private_key(raw, secret)
1014
+ except ValueError as exc:
1015
+ raise CryptnoxError(f"could not decrypt the private key: {exc}") from exc
1016
+ except ValueError as exc:
1017
+ raise CryptnoxError(f"could not load the private key: {exc}") from exc
1018
+
1019
+
1020
+ def _warn_key_file_permissions(app: AppContext, path: str) -> None:
1021
+ import os
1022
+ import stat
1023
+
1024
+ if os.name == "nt": # POSIX mode bits are meaningless on Windows
1025
+ return
1026
+ mode = stat.S_IMODE(os.stat(path).st_mode)
1027
+ if mode & 0o077:
1028
+ app.out.warn(f"{path} is group/world-readable; consider chmod 600.")
1029
+
1030
+
1031
+ def _create_key_object(
1032
+ app: AppContext, adm: PivAdmin, keys, ref: int, mech: int, rsa_crt: bool, slot: str
1033
+ ) -> None:
1034
+ """Dev/eval fallback: create the missing (slot, mechanism) key object via PUT
1035
+ DATA ADMIN, copying modes/role from the builtin profile's slot template."""
1036
+ profile = prof_mod.builtin("cryptnox-default")
1037
+ template = next((k for k in profile.keys if k.ref == ref), None)
1038
+ if template is not None:
1039
+ contact, contactless, role = template.contact, template.contactless, template.role
1040
+ else:
1041
+ contact, contactless = preperso_mod.MODE_PIN, preperso_mod.MODE_NEVER
1042
+ if ref == pivc.KEYREF_DIGITAL_SIGNATURE:
1043
+ role = preperso_mod.ROLE_SIGN
1044
+ elif ref == pivc.KEYREF_KEY_MANAGEMENT or ref >= pivc.KEYREF_RETIRED_FIRST:
1045
+ role = preperso_mod.ROLE_KEY_ESTABLISH
1046
+ else:
1047
+ role = preperso_mod.ROLE_AUTHENTICATE
1048
+ attrs = preperso_mod.ATTR_IMPORTABLE
1049
+ if keyimport.rsa_modulus_len(mech) and rsa_crt:
1050
+ attrs |= preperso_mod.ATTR_RSA_CRT
1051
+ app.out.warn(
1052
+ "creating the key object (PUT DATA ADMIN) - a structural change normally "
1053
+ "done at pre-personalization."
1054
+ )
1055
+ op = preperso_mod.create_key(ref, contact, contactless, mech, role, attrs)
1056
+ adm.select()
1057
+ adm.open(keys)
1058
+ resp = adm.send(
1059
+ preperso_mod.put_data_admin_apdu(preperso_mod.build_bulk([op])),
1060
+ context=f"CREATE KEY {slot}",
1061
+ )
1062
+ if resp.sw == 0x6985:
1063
+ raise CryptnoxError(
1064
+ "the applet is finalized (SECURED): key objects can no longer be created. "
1065
+ "Import into an existing object, or reinstall the applet."
1066
+ )
1067
+ if not resp.ok:
1068
+ raise StatusWordError(resp.sw1, resp.sw2, context=f"CREATE KEY {slot}")
1069
+
1070
+
1071
+ def _execute_key_import(
1072
+ app: AppContext,
1073
+ session,
1074
+ adm: PivAdmin,
1075
+ keys,
1076
+ ref: int,
1077
+ mech: int,
1078
+ plan: list[keyimport.KeyElement],
1079
+ slot: str,
1080
+ *,
1081
+ create_key_object: bool,
1082
+ rsa_crt: bool,
1083
+ ) -> list[dict[str, object]]:
1084
+ """Probe for the (slot, mechanism) key object — optionally creating it — then
1085
+ send the element sequence, one fresh SCP03 session per element (JCOP 4.5)."""
1086
+ _select(session)
1087
+ probe = session.transmit(keyimport.probe_apdu(ref, mech), context=f"probe {slot}")
1088
+ if probe.sw == 0x6A86:
1089
+ if not create_key_object:
1090
+ alg_name = pivc.ALGORITHMS[mech]
1091
+ raise CryptnoxError(
1092
+ f"slot {slot} has no {alg_name} key object: {_IMPORT_SW_HINTS[0x6A88]}."
1093
+ )
1094
+ _create_key_object(app, adm, keys, ref, mech, rsa_crt, slot)
1095
+
1096
+ results: list[dict[str, object]] = []
1097
+ for index, el in enumerate(plan, 1):
1098
+ adm.select()
1099
+ adm.open(keys)
1100
+ body = el.body()
1101
+ label = f"IMPORT {slot} [{el.label}] [{index}/{len(plan)}]"
1102
+ if len(body) > keyimport.SINGLE_APDU_MAX:
1103
+ resp = adm.send_chained(
1104
+ keyimport.INS_CHANGE_REFERENCE_DATA, mech, ref, body, context=label
1105
+ )
1106
+ else:
1107
+ resp = adm.send(keyimport.import_apdu(ref, mech, el), context=label)
1108
+ results.append({"element": el.label, "sw": resp.sw_hex(), "ok": resp.ok})
1109
+ if not resp.ok:
1110
+ hint = _IMPORT_SW_HINTS.get(resp.sw)
1111
+ app.out.warn(
1112
+ "the key is left uninitialised - that is safe; re-run the import "
1113
+ "(it starts with a CLEAR) or generate a key instead."
1114
+ )
1115
+ ctx = f"IMPORT {slot} [{el.label}]" + (f" - {hint}" if hint else "")
1116
+ raise StatusWordError(resp.sw1, resp.sw2, context=ctx)
1117
+ return results
1118
+
1119
+
1120
+ def _post_import_smoke(
1121
+ app: AppContext, session, ref: int, mech: int, key, pin_value: str | None
1122
+ ) -> dict[str, object]:
1123
+ """Prove the imported key: verify the PIN, sign on-card, verify host-side
1124
+ against the key. A SIGN-incapable slot role reports a note, not a failure."""
1125
+ import hashlib
1126
+ import os
1127
+
1128
+ from cryptography.exceptions import InvalidSignature
1129
+ from cryptography.hazmat.primitives.asymmetric import ec as ec_mod
1130
+ from cryptography.hazmat.primitives.asymmetric import padding as rsa_padding
1131
+
1132
+ modulus_len = keyimport.rsa_modulus_len(mech)
1133
+ pin_secret = resolve_secret(
1134
+ redactor=app.redactor,
1135
+ env_var="CRYPTNOX_PIV_PIN",
1136
+ prompt_label="PIV PIN",
1137
+ provided=pin_value,
1138
+ )
1139
+ piv = _select(session)
1140
+ verified = piv.verify_pin(pin_secret)
1141
+ _check_pin_verify(verified)
1142
+ message = os.urandom(32)
1143
+ hash_alg = keyimport.digest_for_mechanism(mech)
1144
+ digest = hashlib.new(hash_alg.name, message).digest()
1145
+ challenge = keyimport.emsa_pkcs1_v15(digest, modulus_len) if modulus_len else digest
1146
+ apdu = perso_mod.general_authenticate_sign_apdu(ref, mech, challenge)
1147
+ if len(apdu.data) > 0xFF: # oversized body: plain-channel ISO chaining
1148
+ resp = session.transmit_chained(apdu, context="GENERAL AUTHENTICATE (smoke)")
1149
+ else:
1150
+ resp = session.transmit(apdu, context="GENERAL AUTHENTICATE (smoke)")
1151
+ if resp.sw == 0x6985:
1152
+ return {"signed": False, "note": "import OK; this slot's role cannot sign on this applet"}
1153
+ if not resp.ok:
1154
+ raise StatusWordError(resp.sw1, resp.sw2, context="GENERAL AUTHENTICATE (smoke)")
1155
+ signature = perso_mod.parse_sign_response(resp.data)
1156
+ public_key = key.public_key()
1157
+ try:
1158
+ if modulus_len:
1159
+ public_key.verify(signature, message, rsa_padding.PKCS1v15(), hash_alg)
1160
+ else:
1161
+ public_key.verify(signature, message, ec_mod.ECDSA(hash_alg))
1162
+ except InvalidSignature as exc:
1163
+ raise CryptnoxError(
1164
+ "the on-card signature does not verify against the imported key - wrong "
1165
+ "key material landed?"
1166
+ ) from exc
1167
+ return {"signed": True, "verified": True}
1168
+
1169
+
1170
+ @perso.command("import-key")
1171
+ @click.option("--slot", required=True, help="Key slot hex, e.g. 9C.")
1172
+ @click.option(
1173
+ "--key",
1174
+ "key_file",
1175
+ required=True,
1176
+ type=click.Path(exists=True, dir_okay=False),
1177
+ help="Private key file (PEM or DER; PKCS#8 or traditional).",
1178
+ )
1179
+ @click.option(
1180
+ "--password",
1181
+ help="(discouraged) key-file password; prefer $CRYPTNOX_PIV_KEY_PASSWORD or the prompt.",
1182
+ )
1183
+ @click.option(
1184
+ "--default-keys",
1185
+ is_flag=True,
1186
+ help="Use the default GlobalPlatform TEST keys "
1187
+ "(publicly known - fine for dev/eval, never for deployment).",
1188
+ )
1189
+ @click.option(
1190
+ "--rsa-form",
1191
+ type=click.Choice(["crt", "plain"]),
1192
+ default="crt",
1193
+ show_default=True,
1194
+ help="RSA private-key element form; must match the key object's CRT attribute.",
1195
+ )
1196
+ @click.option(
1197
+ "--create-key-object",
1198
+ is_flag=True,
1199
+ help="Dev/eval: create the (slot, mechanism) key object first if it is missing "
1200
+ "(structural PUT DATA ADMIN; a finalized applet refuses this).",
1201
+ )
1202
+ @click.option(
1203
+ "--public-out",
1204
+ "public_out",
1205
+ type=click.Path(dir_okay=False),
1206
+ help="Write the derived public-key PEM to FILE.",
1207
+ )
1208
+ @click.option("--pin", "pin_value", help="(discouraged) PIN for the post-import signing check.")
1209
+ @click.option("--no-smoke-test", is_flag=True, help="Skip the post-import signing check.")
1210
+ @click.option("--dry-run", "dry_run", is_flag=True, help="Show the element plan; nothing is sent.")
1211
+ @click.pass_obj
1212
+ def perso_import_key(
1213
+ app: AppContext,
1214
+ slot: str,
1215
+ key_file: str,
1216
+ password: str | None,
1217
+ default_keys: bool,
1218
+ rsa_form: str,
1219
+ create_key_object: bool,
1220
+ public_out: str | None,
1221
+ pin_value: str | None,
1222
+ no_smoke_test: bool,
1223
+ dry_run: bool,
1224
+ ) -> None:
1225
+ """Import an EXTERNAL private key into a PIV slot (over SCP03).
1226
+
1227
+ Use this for a key generated off-card. For an on-card RSA key, `generate-key`
1228
+ works on any slot that has a key object for that mechanism (profile-dependent:
1229
+ cryptnox-default provides ECC objects only, ms-logon adds RSA2048 on 9A).
1230
+ The key goes to the card one element per SCP03 session, starting with a CLEAR,
1231
+ so a re-run of the same import is safe.
1232
+ """
1233
+ import hashlib
1234
+
1235
+ from cryptography.hazmat.primitives import serialization
1236
+
1237
+ ref = int(slot, 16)
1238
+ key = _load_private_key_file(app, Path(key_file).read_bytes(), password)
1239
+ mech = keyimport.mechanism_for_key(key)
1240
+ keyimport.validate_slot_mechanism(ref, mech)
1241
+ alg_name = pivc.ALGORITHMS[mech]
1242
+ modulus_len = keyimport.rsa_modulus_len(mech)
1243
+ plan = keyimport.element_plan(key, rsa_crt=(rsa_form == "crt"))
1244
+ for el in plan:
1245
+ if el.secret:
1246
+ app.redactor.register(el.value)
1247
+ _warn_key_file_permissions(app, key_file)
1248
+
1249
+ spki = key.public_key().public_bytes(
1250
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
1251
+ )
1252
+ fingerprint = hashlib.sha256(spki).hexdigest().upper()
1253
+ steps = [
1254
+ {
1255
+ "element": el.label,
1256
+ "bytes": len(el.body()),
1257
+ "chained": len(el.body()) > keyimport.SINGLE_APDU_MAX,
1258
+ }
1259
+ for el in plan
1260
+ ]
1261
+
1262
+ if dry_run or app.dry_run:
1263
+ result = {
1264
+ "dry_run": True,
1265
+ "slot": slot,
1266
+ "algorithm": alg_name,
1267
+ "rsa_form": rsa_form if modulus_len else None,
1268
+ "elements": steps,
1269
+ "scp03_sessions": len(plan),
1270
+ }
1271
+
1272
+ def human_dry(c: Console) -> None:
1273
+ c.print(f"[bold]DRY RUN[/bold] - import {alg_name} into slot {slot}:")
1274
+ for s in steps:
1275
+ via = "chained" if s["chained"] else "single APDU"
1276
+ c.print(f" {s['element']}: {s['bytes']} bytes ({via}, own SCP03 session)")
1277
+ c.print("\n [dim]Nothing was sent to the card.[/dim]")
1278
+
1279
+ app.out.result(result, human_dry)
1280
+ return
1281
+
1282
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
1283
+ smoke: dict[str, object] | None = None
1284
+ with app.open_session() as session:
1285
+ adm = PivAdmin(session)
1286
+ results = _execute_key_import(
1287
+ app,
1288
+ session,
1289
+ adm,
1290
+ keys,
1291
+ ref,
1292
+ mech,
1293
+ plan,
1294
+ slot,
1295
+ create_key_object=create_key_object,
1296
+ rsa_crt=(rsa_form == "crt"),
1297
+ )
1298
+ if not no_smoke_test:
1299
+ smoke = _post_import_smoke(app, session, ref, mech, key, pin_value)
1300
+
1301
+ pem = key.public_key().public_bytes(
1302
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
1303
+ )
1304
+ if public_out:
1305
+ Path(public_out).write_bytes(pem)
1306
+ payload = {
1307
+ "slot": slot,
1308
+ "algorithm": alg_name,
1309
+ "rsa_form": rsa_form if modulus_len else None,
1310
+ "elements": results,
1311
+ "scp03_sessions": len(plan),
1312
+ "public_key_sha256": fingerprint,
1313
+ "public_out": public_out,
1314
+ "smoke_test": smoke,
1315
+ }
1316
+
1317
+ def human(c: Console) -> None:
1318
+ c.print(f"[green]Imported {alg_name} key into slot {slot}[/green] ({len(plan)} elements).")
1319
+ c.print(f" Public key SHA-256: {fingerprint}")
1320
+ if public_out:
1321
+ c.print(f" Public key PEM -> {public_out}")
1322
+ if smoke is None:
1323
+ c.print(" [dim]Smoke test skipped.[/dim]")
1324
+ elif smoke.get("signed"):
1325
+ c.print(" Smoke test: [green]signed on-card, verified against the key[/green].")
1326
+ else:
1327
+ c.print(f" Smoke test: [yellow]{smoke.get('note')}[/yellow]")
1328
+
1329
+ app.out.result(payload, human)
1330
+
1331
+
1332
+ @perso.command("import-p12")
1333
+ @click.option("--slot", required=True, help="Key slot hex, e.g. 9A.")
1334
+ @click.option(
1335
+ "--p12",
1336
+ "p12_file",
1337
+ required=True,
1338
+ type=click.Path(exists=True, dir_okay=False),
1339
+ help="PKCS#12 container (.p12/.pfx) holding the private key and its certificate.",
1340
+ )
1341
+ @click.option(
1342
+ "--password",
1343
+ help="(discouraged) container password; prefer $CRYPTNOX_PIV_P12_PASSWORD or the prompt.",
1344
+ )
1345
+ @click.option(
1346
+ "--default-keys",
1347
+ is_flag=True,
1348
+ help="Use the default GlobalPlatform TEST keys "
1349
+ "(publicly known - fine for dev/eval, never for deployment).",
1350
+ )
1351
+ @click.option(
1352
+ "--rsa-form",
1353
+ type=click.Choice(["crt", "plain"]),
1354
+ default="crt",
1355
+ show_default=True,
1356
+ help="RSA private-key element form; must match the key object's CRT attribute.",
1357
+ )
1358
+ @click.option(
1359
+ "--create-key-object",
1360
+ is_flag=True,
1361
+ help="Dev/eval: create the (slot, mechanism) key object first if it is missing "
1362
+ "(structural PUT DATA ADMIN; a finalized applet refuses this).",
1363
+ )
1364
+ @click.option("--pin", "pin_value", help="(discouraged) PIN for the post-import signing check.")
1365
+ @click.option("--no-smoke-test", is_flag=True, help="Skip the post-import signing check.")
1366
+ @click.option("--dry-run", "dry_run", is_flag=True, help="Show the plan; nothing is sent.")
1367
+ @click.pass_obj
1368
+ def perso_import_p12(
1369
+ app: AppContext,
1370
+ slot: str,
1371
+ p12_file: str,
1372
+ password: str | None,
1373
+ default_keys: bool,
1374
+ rsa_form: str,
1375
+ create_key_object: bool,
1376
+ pin_value: str | None,
1377
+ no_smoke_test: bool,
1378
+ dry_run: bool,
1379
+ ) -> None:
1380
+ """Import a PKCS#12 credential (private key + certificate) into a PIV slot.
1381
+
1382
+ The one-command device-setup flow for credentials issued by a PKI (e.g. an
1383
+ AD CA for Windows logon / Remote Desktop): the key goes over SCP03 one
1384
+ element per session starting with a CLEAR, then the certificate lands in
1385
+ the slot's container. Extra CA certificates in the container are ignored
1386
+ (PIV containers hold the entity certificate only).
1387
+ """
1388
+ import hashlib
1389
+
1390
+ from cryptography.hazmat.primitives import serialization
1391
+
1392
+ ref = int(slot, 16)
1393
+ if ref not in SLOT_CERT_OBJECT:
1394
+ raise click.BadParameter(
1395
+ f"slot {slot} has no certificate container; import-p12 needs one "
1396
+ "(9A/9C/9D/9E). For a bare key use `piv perso import-key`.",
1397
+ param_hint="--slot",
1398
+ )
1399
+ raw = Path(p12_file).read_bytes()
1400
+ key, cert_der, extras = _load_p12_file(app, raw, password)
1401
+ mech = keyimport.mechanism_for_key(key)
1402
+ keyimport.validate_slot_mechanism(ref, mech)
1403
+ alg_name = pivc.ALGORITHMS[mech]
1404
+ modulus_len = keyimport.rsa_modulus_len(mech)
1405
+
1406
+ # The container must actually pair: certificate public key == private key.
1407
+ from cryptography import x509
1408
+
1409
+ cert_pub = x509.load_der_x509_certificate(cert_der).public_key()
1410
+ if cert_pub.public_bytes(
1411
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
1412
+ ) != key.public_key().public_bytes(
1413
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
1414
+ ):
1415
+ raise CryptnoxError(
1416
+ "the PKCS#12 certificate does not match its private key - refusing to "
1417
+ "import a mismatched pair."
1418
+ )
1419
+
1420
+ plan = keyimport.element_plan(key, rsa_crt=(rsa_form == "crt"))
1421
+ for el in plan:
1422
+ if el.secret:
1423
+ app.redactor.register(el.value)
1424
+ _warn_key_file_permissions(app, p12_file)
1425
+ cert_desc = x509util.describe_certificate(cert_der)
1426
+ fingerprint = (
1427
+ hashlib.sha256(
1428
+ key.public_key().public_bytes(
1429
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
1430
+ )
1431
+ )
1432
+ .hexdigest()
1433
+ .upper()
1434
+ )
1435
+
1436
+ if dry_run or app.dry_run:
1437
+ steps = [
1438
+ {
1439
+ "element": el.label,
1440
+ "bytes": len(el.body()),
1441
+ "chained": len(el.body()) > keyimport.SINGLE_APDU_MAX,
1442
+ }
1443
+ for el in plan
1444
+ ]
1445
+ result = {
1446
+ "dry_run": True,
1447
+ "slot": slot,
1448
+ "algorithm": alg_name,
1449
+ "elements": steps,
1450
+ "certificate": cert_desc,
1451
+ "extra_certs_ignored": len(extras),
1452
+ }
1453
+
1454
+ def human_dry(c: Console) -> None:
1455
+ c.print(f"[bold]DRY RUN[/bold] - import {alg_name} key + certificate into {slot}:")
1456
+ for s in steps:
1457
+ via = "chained" if s["chained"] else "single APDU"
1458
+ c.print(f" {s['element']}: {s['bytes']} bytes ({via}, own SCP03 session)")
1459
+ c.print(f" certificate ({cert_desc.get('subject')}): chained PUT DATA")
1460
+ if extras:
1461
+ c.print(f" [dim]{len(extras)} chain certificate(s) ignored[/dim]")
1462
+ c.print("\n [dim]Nothing was sent to the card.[/dim]")
1463
+
1464
+ app.out.result(result, human_dry)
1465
+ return
1466
+
1467
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
1468
+ smoke: dict[str, object] | None = None
1469
+ with app.open_session() as session:
1470
+ adm = PivAdmin(session)
1471
+ results = _execute_key_import(
1472
+ app,
1473
+ session,
1474
+ adm,
1475
+ keys,
1476
+ ref,
1477
+ mech,
1478
+ plan,
1479
+ slot,
1480
+ create_key_object=create_key_object,
1481
+ rsa_crt=(rsa_form == "crt"),
1482
+ )
1483
+ cert_resp = _import_cert_der(adm, keys, ref, cert_der, label=slot)
1484
+ if not cert_resp.ok:
1485
+ raise StatusWordError(cert_resp.sw1, cert_resp.sw2, context=f"IMPORT cert {slot}")
1486
+ if not no_smoke_test:
1487
+ smoke = _post_import_smoke(app, session, ref, mech, key, pin_value)
1488
+
1489
+ payload = {
1490
+ "slot": slot,
1491
+ "algorithm": alg_name,
1492
+ "rsa_form": rsa_form if modulus_len else None,
1493
+ "elements": results,
1494
+ "certificate": cert_desc,
1495
+ "extra_certs_ignored": len(extras),
1496
+ "public_key_sha256": fingerprint,
1497
+ "smoke_test": smoke,
1498
+ }
1499
+
1500
+ def human(c: Console) -> None:
1501
+ c.print(f"[green]Imported {alg_name} credential into slot {slot}[/green].")
1502
+ c.print(f" Certificate: {cert_desc.get('subject')} (expires {cert_desc.get('not_after')})")
1503
+ if extras:
1504
+ c.print(f" [dim]{len(extras)} chain certificate(s) ignored.[/dim]")
1505
+ if smoke is None:
1506
+ c.print(" [dim]Smoke test skipped.[/dim]")
1507
+ elif smoke.get("signed"):
1508
+ c.print(" Smoke test: [green]signed on-card, verified against the key[/green].")
1509
+ else:
1510
+ c.print(f" Smoke test: [yellow]{smoke.get('note')}[/yellow]")
1511
+
1512
+ app.out.result(payload, human)
1513
+
1514
+
1515
+ def _load_p12_file(app: AppContext, raw: bytes, password: str | None):
1516
+ """Parse a PKCS#12 container, resolving the password only when needed.
1517
+ PKCS#12 cannot distinguish a wrong password from corrupt data, so the first
1518
+ passwordless attempt falling through to a prompt is the best we can do."""
1519
+ if password is not None:
1520
+ try:
1521
+ return keyimport.load_pkcs12(raw, password.encode())
1522
+ except ValueError as exc:
1523
+ raise CryptnoxError(f"could not open the PKCS#12 file: {exc}") from exc
1524
+ try:
1525
+ return keyimport.load_pkcs12(raw, None)
1526
+ except ValueError:
1527
+ secret = resolve_secret(
1528
+ redactor=app.redactor,
1529
+ env_var="CRYPTNOX_PIV_P12_PASSWORD",
1530
+ prompt_label="PKCS#12 password",
1531
+ provided=None,
1532
+ )
1533
+ try:
1534
+ return keyimport.load_pkcs12(raw, secret)
1535
+ except ValueError as exc:
1536
+ raise CryptnoxError(
1537
+ f"could not open the PKCS#12 file (wrong password or corrupt data): {exc}"
1538
+ ) from exc
1539
+
1540
+
1541
+ def _set_verifier_value(
1542
+ app: AppContext,
1543
+ ref: int,
1544
+ label: str,
1545
+ env_var: str,
1546
+ prompt_label: str,
1547
+ pin_value: str | None,
1548
+ default_keys: bool,
1549
+ ) -> None:
1550
+ app.out.warn(f"replaces the current {label} without needing its value (admin channel write).")
1551
+ secret = resolve_secret(
1552
+ redactor=app.redactor, env_var=env_var, prompt_label=prompt_label, provided=pin_value
1553
+ )
1554
+ padded = perso_mod.pad_pin(secret)
1555
+ app.redactor.register(padded)
1556
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
1557
+ with app.open_session() as session:
1558
+ resp = _set_verifier(PivAdmin(session), keys, ref, padded, label=label)
1559
+ info = describe_sw(resp.sw1, resp.sw2)
1560
+ app.out.result(
1561
+ {"ref": f"{ref:02X}", "set": resp.ok, "sw": info.sw_hex()},
1562
+ lambda c: c.print(
1563
+ f"[green]{label} set.[/green]"
1564
+ if resp.ok
1565
+ else f"[red]{label} not set:[/red] {info.message} (SW={info.sw_hex()})"
1566
+ ),
1567
+ )
1568
+
1569
+
1570
+ @perso.command("set-pin")
1571
+ @click.option("--pin", "pin_value", help="(discouraged) PIN on the CLI; prefer prompt/env.")
1572
+ @click.option(
1573
+ "--default-keys",
1574
+ is_flag=True,
1575
+ help="Use the default GlobalPlatform TEST keys "
1576
+ "(publicly known - fine for dev/eval, never for deployment).",
1577
+ )
1578
+ @click.pass_obj
1579
+ def perso_set_pin(app: AppContext, pin_value: str | None, default_keys: bool) -> None:
1580
+ """Set the initial PIV PIN value (CHANGE REFERENCE DATA ADMIN over SCP03)."""
1581
+ _set_verifier_value(
1582
+ app, pivc.REF_PIV_PIN, "PIN", "CRYPTNOX_PIV_NEW_PIN", "New PIV PIN", pin_value, default_keys
1583
+ )
1584
+
1585
+
1586
+ @perso.command("set-puk")
1587
+ @click.option("--puk", "pin_value", help="(discouraged) PUK on the CLI; prefer prompt/env.")
1588
+ @click.option(
1589
+ "--default-keys",
1590
+ is_flag=True,
1591
+ help="Use the default GlobalPlatform TEST keys "
1592
+ "(publicly known - fine for dev/eval, never for deployment).",
1593
+ )
1594
+ @click.pass_obj
1595
+ def perso_set_puk(app: AppContext, pin_value: str | None, default_keys: bool) -> None:
1596
+ """Set the initial PIV PUK value (CHANGE REFERENCE DATA ADMIN over SCP03)."""
1597
+ _set_verifier_value(
1598
+ app, pivc.REF_PUK, "PUK", "CRYPTNOX_PIV_NEW_PUK", "New PIV PUK", pin_value, default_keys
1599
+ )
1600
+
1601
+
1602
+ def _resolve_asym_mech(algorithm: str) -> int:
1603
+ mech = perso_mod.ALGORITHMS.get(algorithm.upper())
1604
+ if mech is None or mech not in pivc.SUPPORTED_ALGORITHMS:
1605
+ raise click.BadParameter(f"algorithm {algorithm!r} not supported by this applet")
1606
+ return mech
1607
+
1608
+
1609
+ def _load_spki(pubkey_file: str) -> bytes:
1610
+ from cryptography.hazmat.primitives import serialization
1611
+
1612
+ pub = serialization.load_pem_public_key(Path(pubkey_file).read_bytes())
1613
+ return pub.public_bytes(
1614
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
1615
+ )
1616
+
1617
+
1618
+ def _sign_on_card(
1619
+ app: AppContext,
1620
+ ref: int,
1621
+ mech: int,
1622
+ pin_value: str | None,
1623
+ build,
1624
+ *,
1625
+ pin_bytes: bytes | None = None,
1626
+ ):
1627
+ """Verify PIN, then run ``build(signer)`` where signer computes an on-card
1628
+ signature over a digest via GENERAL AUTHENTICATE.
1629
+
1630
+ For RSA mechanisms the card performs a raw private-key operation over a full
1631
+ modulus-length block, so the digest is EMSA-PKCS1-v1_5 encoded here first; the
1632
+ resulting command exceeds a short APDU and goes out via ISO command chaining.
1633
+ """
1634
+ pin = (
1635
+ pin_bytes
1636
+ if pin_bytes is not None
1637
+ else resolve_secret(
1638
+ redactor=app.redactor,
1639
+ env_var="CRYPTNOX_PIV_PIN",
1640
+ prompt_label="PIV PIN",
1641
+ provided=pin_value,
1642
+ )
1643
+ )
1644
+ with app.open_session() as session:
1645
+ return _sign_with_session(session, ref, mech, pin, build)
1646
+
1647
+
1648
+ @perso.command("generate-csr")
1649
+ @click.option("--slot", required=True, help="Key slot hex, e.g. 9A.")
1650
+ @click.option("--subject", required=True, help='e.g. "CN=John Doe,O=Cryptnox,C=CH"')
1651
+ @click.option(
1652
+ "--public-key", "pubkey_file", required=True, type=click.Path(exists=True, dir_okay=False)
1653
+ )
1654
+ @click.option("--algorithm", default="ECCP256", show_default=True)
1655
+ @click.option("--pin", "pin_value", help="(discouraged) PIN on the CLI; prefer prompt/env.")
1656
+ @click.option("--out", "out_", type=click.Path(dir_okay=False))
1657
+ @click.pass_obj
1658
+ def perso_generate_csr(
1659
+ app: AppContext,
1660
+ slot: str,
1661
+ subject: str,
1662
+ pubkey_file: str,
1663
+ algorithm: str,
1664
+ pin_value: str | None,
1665
+ out_: str | None,
1666
+ ) -> None:
1667
+ """Generate a PKCS#10 CSR; the signature is computed ON-CARD by the slot key."""
1668
+ ref = int(slot, 16)
1669
+ mech = _resolve_asym_mech(algorithm)
1670
+ spki = _load_spki(pubkey_file)
1671
+ pem = _sign_on_card(
1672
+ app, ref, mech, pin_value, lambda s: csr_mod.build_csr(subject, spki, mech, s)
1673
+ )
1674
+ if out_:
1675
+ Path(out_).write_bytes(pem)
1676
+ app.out.result(
1677
+ {"slot": slot, "subject": subject, "out": out_, "csr_pem": None if out_ else pem.decode()},
1678
+ lambda c: c.print(
1679
+ f"[green]CSR written to {out_}[/green]" if out_ else pem.decode().rstrip()
1680
+ ),
1681
+ )
1682
+
1683
+
1684
+ @perso.command("self-sign-cert")
1685
+ @click.option("--slot", required=True, help="Key slot hex, e.g. 9A.")
1686
+ @click.option("--subject", required=True, help='e.g. "CN=Test User"')
1687
+ @click.option(
1688
+ "--public-key", "pubkey_file", required=True, type=click.Path(exists=True, dir_okay=False)
1689
+ )
1690
+ @click.option("--algorithm", default="ECCP256", show_default=True)
1691
+ @click.option("--days", default=365, show_default=True, type=int)
1692
+ @click.option("--pin", "pin_value", help="(discouraged) PIN on the CLI; prefer prompt/env.")
1693
+ @click.option("--out", "out_", type=click.Path(dir_okay=False))
1694
+ @click.pass_obj
1695
+ def perso_self_sign_cert(
1696
+ app: AppContext,
1697
+ slot: str,
1698
+ subject: str,
1699
+ pubkey_file: str,
1700
+ algorithm: str,
1701
+ days: int,
1702
+ pin_value: str | None,
1703
+ out_: str | None,
1704
+ ) -> None:
1705
+ """Build a self-signed certificate (DEV/TEST only), signed ON-CARD."""
1706
+ import datetime
1707
+ import secrets
1708
+
1709
+ app.out.warn("Self-signed certificates are for development/testing only.")
1710
+ ref = int(slot, 16)
1711
+ mech = _resolve_asym_mech(algorithm)
1712
+ spki = _load_spki(pubkey_file)
1713
+ serial = int.from_bytes(secrets.token_bytes(8), "big") | 1
1714
+ not_before = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
1715
+ not_after = not_before + datetime.timedelta(days=days)
1716
+ pem = _sign_on_card(
1717
+ app,
1718
+ ref,
1719
+ mech,
1720
+ pin_value,
1721
+ lambda s: csr_mod.build_self_signed(
1722
+ subject,
1723
+ spki,
1724
+ mech,
1725
+ s,
1726
+ serial=serial,
1727
+ not_before=not_before.isoformat(),
1728
+ not_after=not_after.isoformat(),
1729
+ ),
1730
+ )
1731
+ if out_:
1732
+ Path(out_).write_bytes(pem)
1733
+ app.out.result(
1734
+ {"slot": slot, "subject": subject, "out": out_, "cert_pem": None if out_ else pem.decode()},
1735
+ lambda c: c.print(
1736
+ f"[green]Self-signed cert written to {out_}[/green]" if out_ else pem.decode().rstrip()
1737
+ ),
1738
+ )
1739
+
1740
+
1741
+ @perso.command("import-cert")
1742
+ @click.option("--slot", required=True, help="Key slot hex, e.g. 9A.")
1743
+ @click.option("--cert", "cert_file", required=True, type=click.Path(exists=True, dir_okay=False))
1744
+ @click.option(
1745
+ "--default-keys",
1746
+ is_flag=True,
1747
+ help="Use the default GlobalPlatform TEST keys "
1748
+ "(publicly known - fine for dev/eval, never for deployment).",
1749
+ )
1750
+ @click.pass_obj
1751
+ def perso_import_cert(app: AppContext, slot: str, cert_file: str, default_keys: bool) -> None:
1752
+ """Import an X.509 certificate into the slot's PIV container (over SCP03)."""
1753
+ from cryptography import x509
1754
+ from cryptography.hazmat.primitives import serialization
1755
+
1756
+ ref = int(slot, 16)
1757
+ if ref not in SLOT_CERT_OBJECT:
1758
+ raise click.BadParameter(f"slot {slot} has no certificate container")
1759
+ raw = Path(cert_file).read_bytes()
1760
+ cert = (
1761
+ x509.load_pem_x509_certificate(raw)
1762
+ if raw.lstrip().startswith(b"-----")
1763
+ else x509.load_der_x509_certificate(raw)
1764
+ )
1765
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
1766
+ with app.open_session() as session:
1767
+ resp = _import_cert_der(
1768
+ PivAdmin(session),
1769
+ keys,
1770
+ ref,
1771
+ cert.public_bytes(serialization.Encoding.DER),
1772
+ label=slot,
1773
+ )
1774
+ info = describe_sw(resp.sw1, resp.sw2)
1775
+ app.out.result(
1776
+ {"slot": slot, "imported": resp.ok, "sw": info.sw_hex()},
1777
+ lambda c: c.print(
1778
+ f"[green]Certificate imported to slot {slot}.[/green]"
1779
+ if resp.ok
1780
+ else f"[red]Import failed:[/red] {info.message} (SW={info.sw_hex()})"
1781
+ ),
1782
+ )
1783
+
1784
+
1785
+ def _emit_object(app: AppContext, name: str, data: bytes, out_: str | None) -> None:
1786
+ if out_:
1787
+ Path(out_).write_bytes(data)
1788
+ app.out.result(
1789
+ {
1790
+ "object": name,
1791
+ "length": len(data),
1792
+ "out": out_,
1793
+ "hex": None if out_ else data.hex().upper(),
1794
+ },
1795
+ lambda c: c.print(
1796
+ f"[green]{name}[/green] ({len(data)} bytes) -> {out_}"
1797
+ if out_
1798
+ else f"{name} ({len(data)} bytes): {data.hex().upper()}"
1799
+ ),
1800
+ )
1801
+
1802
+
1803
+ @perso.command("generate-chuid")
1804
+ @click.option("--out", "out_", type=click.Path(dir_okay=False), help="Write object bytes to FILE.")
1805
+ @click.pass_obj
1806
+ def perso_generate_chuid(app: AppContext, out_: str | None) -> None:
1807
+ """Generate a minimal CHUID object (unsigned, dev/test)."""
1808
+ _emit_object(app, "chuid", piv_objects.generate_chuid(), out_)
1809
+
1810
+
1811
+ @perso.command("generate-ccc")
1812
+ @click.option("--out", "out_", type=click.Path(dir_okay=False), help="Write object bytes to FILE.")
1813
+ @click.pass_obj
1814
+ def perso_generate_ccc(app: AppContext, out_: str | None) -> None:
1815
+ """Generate a minimal Card Capability Container object (dev/test)."""
1816
+ _emit_object(app, "ccc", piv_objects.generate_ccc(), out_)
1817
+
1818
+
1819
+ @perso.command("generate-discovery")
1820
+ @click.option("--out", "out_", type=click.Path(dir_okay=False), help="Write object bytes to FILE.")
1821
+ @click.pass_obj
1822
+ def perso_generate_discovery(app: AppContext, out_: str | None) -> None:
1823
+ """Generate the Discovery object (7E)."""
1824
+ _emit_object(app, "discovery", piv_objects.generate_discovery(), out_)
1825
+
1826
+
1827
+ @perso.command("write-object")
1828
+ @click.option("--object", "object_name", required=True, help="Object name, e.g. chuid.")
1829
+ @click.option("--file", "file_", required=True, type=click.Path(exists=True, dir_okay=False))
1830
+ @click.option(
1831
+ "--default-keys",
1832
+ is_flag=True,
1833
+ help="Use the default GlobalPlatform TEST keys "
1834
+ "(publicly known - fine for dev/eval, never for deployment).",
1835
+ )
1836
+ @click.pass_obj
1837
+ def perso_write_object(app: AppContext, object_name: str, file_: str, default_keys: bool) -> None:
1838
+ """Write a data object's content into its container (PUT DATA over SCP03)."""
1839
+ obj = object_by_name(object_name)
1840
+ if obj is None:
1841
+ raise click.BadParameter(f"unknown object {object_name!r}")
1842
+ app.out.warn(f"overwrites the {object_name} container's current content (admin channel write).")
1843
+ data = Path(file_).read_bytes()
1844
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
1845
+ with app.open_session() as session:
1846
+ adm = PivAdmin(session)
1847
+ adm.select()
1848
+ adm.open(keys)
1849
+ resp = adm.send(perso_mod.put_data_apdu(obj.oid, data), context=f"PUT DATA {object_name}")
1850
+ info = describe_sw(resp.sw1, resp.sw2)
1851
+ app.out.result(
1852
+ {"object": object_name, "written": resp.ok, "sw": info.sw_hex()},
1853
+ lambda c: c.print(
1854
+ f"[green]Wrote {object_name}.[/green]"
1855
+ if resp.ok
1856
+ else f"[red]Write failed:[/red] {info.message} (SW={info.sw_hex()})"
1857
+ ),
1858
+ )
1859
+
1860
+
1861
+ @perso.command("write-standard-objects")
1862
+ @click.option(
1863
+ "--default-keys",
1864
+ is_flag=True,
1865
+ help="Use the default GlobalPlatform TEST keys "
1866
+ "(publicly known - fine for dev/eval, never for deployment).",
1867
+ )
1868
+ @click.pass_obj
1869
+ def perso_write_standard_objects(app: AppContext, default_keys: bool) -> None:
1870
+ """Generate and write the standard data objects (CHUID, CCC) into their containers."""
1871
+ app.out.warn(
1872
+ "overwrites the current content of the chuid and ccc containers (admin channel write)."
1873
+ )
1874
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
1875
+ with app.open_session() as session:
1876
+ results = _write_standard(PivAdmin(session), keys, ("chuid", "ccc"))
1877
+
1878
+ def human(c: Console) -> None:
1879
+ for r in results:
1880
+ mark = "[green]ok[/green]" if r["ok"] else f"[red]{r['sw']}[/red]"
1881
+ c.print(f" {mark} {r['object']}")
1882
+
1883
+ app.out.result({"objects": results}, human)
1884
+
1885
+
1886
+ @perso.command("smoke-test")
1887
+ @click.option("--slot", default="9C", show_default=True, help="A SIGN-capable slot.")
1888
+ @click.option("--algorithm", default="ECCP256", show_default=True)
1889
+ @click.option("--pin", "pin_value", help="(discouraged) PIN on the CLI; prefer prompt/env.")
1890
+ @click.pass_obj
1891
+ def perso_smoke_test(app: AppContext, slot: str, algorithm: str, pin_value: str | None) -> None:
1892
+ """Fast post-perso check: verify the PIN and perform one on-card signature."""
1893
+ import os
1894
+
1895
+ ref = int(slot, 16)
1896
+ mech = _resolve_asym_mech(algorithm)
1897
+ # Digest size must match the mechanism's hash (SHA-256=32, SHA-384=48); a fixed 32-byte
1898
+ # challenge makes the card reject ECCP384 with 6A80. Mirror the import/quickstart paths.
1899
+ digest = os.urandom(keyimport.digest_for_mechanism(mech).digest_size)
1900
+ signature = _sign_on_card(app, ref, mech, pin_value, lambda s: s(digest))
1901
+ app.out.result(
1902
+ {"slot": slot, "pin_verified": True, "signed": True, "signature_len": len(signature)},
1903
+ lambda c: c.print(
1904
+ f"[green]Smoke test OK[/green]: PIN verified and slot {slot} produced a "
1905
+ f"{len(signature)}-byte signature."
1906
+ ),
1907
+ )
1908
+
1909
+
1910
+ @command.command("validate")
1911
+ @click.option("--profile", default=None, help="Informational label for the report.")
1912
+ @click.pass_obj
1913
+ def validate(app: AppContext, profile: str | None) -> None:
1914
+ """Compatibility / consistency check (NOT a NIST/FIPS validation)."""
1915
+ with app.open_session() as session:
1916
+ st = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
1917
+ piv = _select(session)
1918
+ certs_loaded = {
1919
+ ref: _read_cert_der(piv, name) is not None for ref, name in SLOT_CERT_OBJECT.items()
1920
+ }
1921
+ objs = st.piv_objects
1922
+ checks: list[dict[str, object]] = []
1923
+
1924
+ def add(name: str, ok: bool, detail: str = "") -> None:
1925
+ checks.append({"check": name, "ok": ok, "detail": detail})
1926
+
1927
+ add("PIV applet selectable", st.piv_apt is not None, st.piv.label)
1928
+ add("PIN configured", bool(st.piv_pin and st.piv_pin.configured))
1929
+ add("PUK configured", bool(st.piv_puk and st.piv_puk.configured))
1930
+ add("CHUID present", bool(objs.get("chuid")))
1931
+ add("CCC present", bool(objs.get("ccc")))
1932
+ add("Discovery present", bool(objs.get("discovery")))
1933
+ for ref in SLOT_CERT_OBJECT:
1934
+ add(f"certificate {ref:02X} loaded", certs_loaded[ref])
1935
+ supported = ", ".join(pivc.ALGORITHMS[a] for a in sorted(pivc.SUPPORTED_ALGORITHMS))
1936
+ add("Supported algorithms reported", True, supported)
1937
+ payload = {
1938
+ "profile": profile,
1939
+ "checks": checks,
1940
+ "note": "Compatibility/consistency check only - NOT a NIST/FIPS/SP800-73 validation.",
1941
+ }
1942
+
1943
+ def human(c: Console) -> None:
1944
+ table = app.out.table("Check", "Result", "Detail")
1945
+ for ch in checks:
1946
+ mark = "[green]pass[/green]" if ch["ok"] else "[yellow]absent[/yellow]"
1947
+ table.add_row(str(ch["check"]), mark, str(ch["detail"]))
1948
+ c.print(table)
1949
+ c.print(f"\n[dim]{payload['note']}[/dim]")
1950
+
1951
+ app.out.result(payload, human)
1952
+
1953
+
1954
+ # -------------------------------------------------------------- quickstart --- #
1955
+ @dataclass(frozen=True)
1956
+ class QuickstartOptions:
1957
+ slot: int
1958
+ mechanism: int
1959
+ cert_mode: str # self-signed | csr | none
1960
+ include_preperso: bool
1961
+
1962
+
1963
+ @dataclass(frozen=True)
1964
+ class CardFacts:
1965
+ """What quickstart needs to know about the card before planning."""
1966
+
1967
+ pin_configured: bool
1968
+ puk_configured: bool
1969
+ key_object_present: bool # a (slot, mechanism) key object exists
1970
+ cert_present: bool
1971
+ chuid_present: bool
1972
+ ccc_present: bool
1973
+
1974
+
1975
+ @dataclass(frozen=True)
1976
+ class PlannedStep:
1977
+ step: str
1978
+ run: bool
1979
+ reason: str | None = None
1980
+
1981
+
1982
+ def _plan_quickstart(facts: CardFacts, opts: QuickstartOptions) -> list[PlannedStep]:
1983
+ """Pure planner: which steps run for this card state. Raises when the card
1984
+ needs pre-personalization and the caller did not opt into it."""
1985
+ steps: list[PlannedStep] = []
1986
+ if not facts.key_object_present:
1987
+ if not opts.include_preperso:
1988
+ raise CryptnoxError(
1989
+ "the PIV structure (containers, verifiers, key objects) is missing on "
1990
+ f"this card. Lay it down first:\n {CLI_NAME} factory piv preperso "
1991
+ "load-config --profile cryptnox-default\nor re-run quickstart with "
1992
+ "--include-preperso (dev/eval cards)."
1993
+ )
1994
+ steps.append(PlannedStep("preperso-load-config", True, "structure missing"))
1995
+ else:
1996
+ steps.append(PlannedStep("preperso-load-config", False, "structure present"))
1997
+ steps.append(
1998
+ PlannedStep(
1999
+ "set-pin",
2000
+ not facts.pin_configured,
2001
+ None if not facts.pin_configured else "PIN already set",
2002
+ )
2003
+ )
2004
+ steps.append(
2005
+ PlannedStep(
2006
+ "set-puk",
2007
+ not facts.puk_configured,
2008
+ None if not facts.puk_configured else "PUK already set",
2009
+ )
2010
+ )
2011
+ # A present certificate marks the slot as done: never silently replace a
2012
+ # certified key. Without a certificate the key is regenerated (nothing
2013
+ # references it yet).
2014
+ fresh_slot = not facts.cert_present
2015
+ steps.append(
2016
+ PlannedStep(
2017
+ "generate-key",
2018
+ fresh_slot,
2019
+ None if fresh_slot else "certificate present - keeping the existing key",
2020
+ )
2021
+ )
2022
+ if opts.cert_mode == "none":
2023
+ steps.append(PlannedStep("certificate", False, "--cert-mode none"))
2024
+ else:
2025
+ steps.append(
2026
+ PlannedStep("certificate", fresh_slot, None if fresh_slot else "already present")
2027
+ )
2028
+ steps.append(
2029
+ PlannedStep(
2030
+ "write-chuid",
2031
+ not facts.chuid_present,
2032
+ None if not facts.chuid_present else "already present",
2033
+ )
2034
+ )
2035
+ steps.append(
2036
+ PlannedStep(
2037
+ "write-ccc", not facts.ccc_present, None if not facts.ccc_present else "already present"
2038
+ )
2039
+ )
2040
+ steps.append(PlannedStep("smoke-test", True))
2041
+ return steps
2042
+
2043
+
2044
+ def _pin_truth(piv: PivApplet, pin: bytes) -> tuple[str, int | None]:
2045
+ """Distinguish a set PIN from a created-but-valueless verifier, with one VERIFY.
2046
+
2047
+ The status probe (empty VERIFY) cannot tell them apart: OpenFIPS201 answers
2048
+ 63Cx with the retry counter for a verifier the pre-perso structure created even
2049
+ when no value was ever set, and only a real VERIFY reveals the difference -
2050
+ observed on hardware 2026-08-21. Returns:
2051
+
2052
+ * ``("match", None)`` - PIN is set and the provided value is correct (9000)
2053
+ * ``("empty", None)`` - verifier exists but holds no value (6A88, no retry
2054
+ consumed - the applet rejects the reference, not the value)
2055
+ * ``("mismatch", tries)`` - PIN is set and the provided value is wrong (63Cx;
2056
+ one retry consumed - the same retry the certificate/smoke-test step would
2057
+ have consumed, since every quickstart path verifies the PIN at least once)
2058
+ """
2059
+ resp = piv.verify_pin(pin)
2060
+ if resp.ok:
2061
+ return "match", None
2062
+ if resp.sw == 0x6A88:
2063
+ return "empty", None
2064
+ if resp.sw1 == 0x63 and (resp.sw2 & 0xF0) == 0xC0:
2065
+ return "mismatch", resp.sw2 & 0x0F
2066
+ raise StatusWordError(resp.sw1, resp.sw2, context="VERIFY PIN (quickstart pre-flight)")
2067
+
2068
+
2069
+ def _apply_pin_truth(steps: list[PlannedStep], verdict: str) -> list[PlannedStep]:
2070
+ """Adjust a plan after the pre-flight PIN check.
2071
+
2072
+ ``empty`` means the structure was laid down (e.g. a manual pre-personalization)
2073
+ but personalization never happened: the set-pin skip was a false positive, and
2074
+ the PUK - created by the same structure - is assumed valueless too, so both
2075
+ flips run. Setting them loses nothing: there is no value to preserve. ``match``
2076
+ upgrades the skip reason to say the PIN was actually verified.
2077
+ """
2078
+ if verdict == "match":
2079
+ return [
2080
+ PlannedStep(s.step, s.run, "PIN already set (verified)")
2081
+ if s.step == "set-pin" and not s.run
2082
+ else s
2083
+ for s in steps
2084
+ ]
2085
+ if verdict != "empty":
2086
+ return steps
2087
+ out: list[PlannedStep] = []
2088
+ for s in steps:
2089
+ if s.step == "set-pin" and not s.run:
2090
+ out.append(PlannedStep("set-pin", True, "verifier exists but holds no value"))
2091
+ elif s.step == "set-puk" and not s.run:
2092
+ out.append(PlannedStep("set-puk", True, "verifier assumed valueless, like the PIN"))
2093
+ else:
2094
+ out.append(s)
2095
+ return out
2096
+
2097
+
2098
+ def _collect_quickstart_facts(session, ref: int, mech: int) -> tuple[CardFacts, str]:
2099
+ """Read-only detection pass: lifecycle state, verifier status, object presence,
2100
+ and the non-destructive (slot, mechanism) key-object probe."""
2101
+ st = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
2102
+ piv = _select(session)
2103
+ probe = session.transmit(keyimport.probe_apdu(ref, mech), context="probe quickstart")
2104
+ # "Certificate present" means a PARSEABLE certificate: a created-but-unused
2105
+ # container is zero-length per SP 800-73 4.1.1, and factory containers can
2106
+ # hold non-certificate placeholder data - neither should stop quickstart
2107
+ # from provisioning the slot.
2108
+ obj_name = SLOT_CERT_OBJECT.get(ref)
2109
+ cert_present = obj_name is not None and _read_cert_der(piv, obj_name) is not None
2110
+ objs = st.piv_objects or {}
2111
+ facts = CardFacts(
2112
+ pin_configured=bool(st.piv_pin and st.piv_pin.configured),
2113
+ puk_configured=bool(st.piv_puk and st.piv_puk.configured),
2114
+ key_object_present=probe.sw != 0x6A86,
2115
+ cert_present=cert_present,
2116
+ chuid_present=bool(objs.get("chuid")),
2117
+ ccc_present=bool(objs.get("ccc")),
2118
+ )
2119
+ return facts, st.piv.label
2120
+
2121
+
2122
+ @command.command("quickstart")
2123
+ @click.option(
2124
+ "--slot",
2125
+ default="9C",
2126
+ show_default=True,
2127
+ help="Key slot hex. Certificate modes need a SIGN-role slot (9C on this applet).",
2128
+ )
2129
+ @click.option(
2130
+ "--algorithm",
2131
+ default="ECCP256",
2132
+ show_default=True,
2133
+ help="ECCP256, ECCP384, RSA2048 or RSA3072; the slot must have a key object for it "
2134
+ "(RSA: only ms-logon's 9A among the built-in profiles — pass RSA2048 explicitly "
2135
+ "for Windows, which does not enumerate EC keys).",
2136
+ )
2137
+ @click.option("--subject", default="CN=Cryptnox Dev Card", show_default=True)
2138
+ @click.option("--days", default=365, show_default=True, type=int)
2139
+ @click.option(
2140
+ "--cert-mode",
2141
+ type=click.Choice(["self-signed", "csr", "none"]),
2142
+ default="self-signed",
2143
+ show_default=True,
2144
+ help="self-signed: sign on-card and import. csr: write a CSR for an external CA. "
2145
+ "none: keys/objects only.",
2146
+ )
2147
+ @click.option(
2148
+ "--csr-out",
2149
+ type=click.Path(dir_okay=False),
2150
+ help="Where to write the CSR (required with --cert-mode csr).",
2151
+ )
2152
+ @click.option(
2153
+ "--cert-out",
2154
+ type=click.Path(dir_okay=False),
2155
+ help="Also write the self-signed certificate PEM to FILE.",
2156
+ )
2157
+ @click.option(
2158
+ "--public-out", type=click.Path(dir_okay=False), help="Also write the public-key PEM to FILE."
2159
+ )
2160
+ @click.option(
2161
+ "--include-preperso",
2162
+ is_flag=True,
2163
+ help="If the PIV structure is missing, lay it down first (a MANUFACTURING-style "
2164
+ "step for dev/eval cards).",
2165
+ )
2166
+ @click.option(
2167
+ "--profile",
2168
+ "profile_name",
2169
+ default="cryptnox-default",
2170
+ show_default=True,
2171
+ help="Built-in pre-perso profile used by --include-preperso (e.g. ms-logon for a "
2172
+ "SIGN-capable 9A / Windows logon card).",
2173
+ )
2174
+ @click.option(
2175
+ "--default-keys",
2176
+ is_flag=True,
2177
+ help="Use the default GlobalPlatform TEST keys "
2178
+ "(publicly known - fine for dev/eval, never for deployment).",
2179
+ )
2180
+ @click.option(
2181
+ "--dry-run", "dry_run", is_flag=True, help="Detect, plan and show the steps; write nothing."
2182
+ )
2183
+ @click.pass_obj
2184
+ def quickstart(
2185
+ app: AppContext,
2186
+ slot: str,
2187
+ algorithm: str,
2188
+ subject: str,
2189
+ days: int,
2190
+ cert_mode: str,
2191
+ csr_out: str | None,
2192
+ cert_out: str | None,
2193
+ public_out: str | None,
2194
+ include_preperso: bool,
2195
+ profile_name: str,
2196
+ default_keys: bool,
2197
+ dry_run: bool,
2198
+ ) -> None:
2199
+ """One-shot personalization: a blank or part-done card -> usable by standard
2200
+ PIV tooling (yubico-piv-tool, OpenSC).
2201
+
2202
+ Chains [preperso] -> PIN -> PUK -> key -> certificate -> CHUID/CCC ->
2203
+ smoke-test over one card connection, skipping whatever is already done, and
2204
+ ends with the commands to verify the result. Contact reader required.
2205
+ """
2206
+ import datetime
2207
+ import hashlib
2208
+ import os
2209
+ import secrets as pysecrets
2210
+ import sys
2211
+
2212
+ from cryptography import x509
2213
+ from cryptography.hazmat.primitives import serialization
2214
+
2215
+ ref = int(slot, 16)
2216
+ # ms-logon on 9A is the Windows shape, and the only built-in profile that gives a slot
2217
+ # an RSA key object. It matters because the Windows inbox minidriver does not enumerate
2218
+ # EC keys at all (confirmed 2026-08-14, reproduced on a YubiKey, so a Windows limit
2219
+ # rather than ours) -- an ECC 9A is unusable for the very purpose ms-logon is chosen for.
2220
+ windows_rsa_shape = profile_name == "ms-logon" and ref == pivc.KEYREF_PIV_AUTH
2221
+
2222
+ algorithm_defaulted = (
2223
+ click.get_current_context().get_parameter_source("algorithm")
2224
+ == click.core.ParameterSource.DEFAULT
2225
+ )
2226
+ if windows_rsa_shape and algorithm_defaulted:
2227
+ # The default stays ECC everywhere, but don't let it land silently on the one
2228
+ # shape whose whole purpose is Windows: an ECC 9A card cannot log on there. An
2229
+ # operator who passed --algorithm made a choice; this fires only on the default.
2230
+ app.out.warn(
2231
+ "profile ms-logon on slot 9A defaulting to ECCP256 - but the Windows "
2232
+ "inbox minidriver does not enumerate EC keys, so this card will NOT work "
2233
+ "for Windows logon/RDP/TLS. For Windows, re-run with --algorithm RSA2048 "
2234
+ "(generated on-card; ms-logon's 9A has the RSA key object)."
2235
+ )
2236
+
2237
+ alg = algorithm.upper()
2238
+ if alg.startswith("RSA") and not windows_rsa_shape:
2239
+ raise click.BadParameter(
2240
+ "on-card RSA needs a slot that has an RSA key object, and among the built-in "
2241
+ "profiles only ms-logon's 9A has one - so this combination would fail on the "
2242
+ "card. Use `--profile ms-logon --slot 9A`, or generate directly with `piv perso "
2243
+ "generate-key` if this card is known to have the object, or import an off-card "
2244
+ "key with `piv perso import-key`.",
2245
+ param_hint="--algorithm",
2246
+ )
2247
+
2248
+ mech = perso_mod.ALGORITHMS.get(alg)
2249
+ if mech is None or mech not in pivc.SUPPORTED_ALGORITHMS:
2250
+ raise click.BadParameter(f"algorithm {algorithm!r} not supported by this applet")
2251
+ keyimport.validate_slot_mechanism(ref, mech)
2252
+ profile = prof_mod.builtin(profile_name) # validates the name early (exit 8 on typo)
2253
+ if cert_mode != "none" and ref not in (
2254
+ pivc.KEYREF_PIV_AUTH,
2255
+ pivc.KEYREF_DIGITAL_SIGNATURE,
2256
+ ):
2257
+ raise click.BadParameter(
2258
+ "certificate modes sign on-card and need a SIGN-capable slot: 9C always; "
2259
+ "9A only on cards pre-personalized with a SIGN-capable 9A (the ms-logon "
2260
+ "profile) - a default-profile 9A fails the certificate step with 6985.",
2261
+ param_hint="--slot",
2262
+ )
2263
+ if cert_mode == "csr" and not csr_out:
2264
+ raise click.BadParameter("--cert-mode csr requires --csr-out", param_hint="--csr-out")
2265
+ if cert_out and cert_mode != "self-signed":
2266
+ raise click.BadParameter(
2267
+ "--cert-out applies to --cert-mode self-signed", param_hint="--cert-out"
2268
+ )
2269
+
2270
+ with app.open_session() as session:
2271
+ facts, state_before = _collect_quickstart_facts(session, ref, mech)
2272
+ steps = _plan_quickstart(facts, QuickstartOptions(ref, mech, cert_mode, include_preperso))
2273
+
2274
+ if dry_run or app.dry_run:
2275
+ planned = [
2276
+ {"step": s.step, "status": "would-run" if s.run else "skip", "reason": s.reason}
2277
+ for s in steps
2278
+ ]
2279
+
2280
+ def human_dry(c: Console) -> None:
2281
+ c.print(f"[bold]DRY RUN[/bold] - card state: {state_before}")
2282
+ table = app.out.table("Step", "Plan", "Reason")
2283
+ for p in planned:
2284
+ mark = (
2285
+ "[cyan]would run[/cyan]"
2286
+ if p["status"] == "would-run"
2287
+ else "[dim]skip[/dim]"
2288
+ )
2289
+ table.add_row(str(p["step"]), mark, str(p["reason"] or ""))
2290
+ c.print(table)
2291
+ c.print("\n [dim]Nothing was sent to the card.[/dim]")
2292
+
2293
+ app.out.result(
2294
+ {
2295
+ "quickstart": True,
2296
+ "dry_run": True,
2297
+ "state_before": state_before,
2298
+ "steps": planned,
2299
+ },
2300
+ human_dry,
2301
+ )
2302
+ return
2303
+
2304
+ # Resolve keys and the PIN before the plan is final: when the status probe
2305
+ # reports the PIN as set, a pre-flight VERIFY establishes the truth (the probe
2306
+ # cannot tell a set PIN from a created-but-valueless verifier), and the plan
2307
+ # may change as a result.
2308
+ keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
2309
+ run_set = {s.step for s in steps if s.run}
2310
+ pin_secret = resolve_secret(
2311
+ redactor=app.redactor,
2312
+ env_var="CRYPTNOX_PIV_NEW_PIN" if "set-pin" in run_set else "CRYPTNOX_PIV_PIN",
2313
+ prompt_label="New PIV PIN" if "set-pin" in run_set else "PIV PIN",
2314
+ )
2315
+ if "set-pin" not in run_set:
2316
+ verdict, tries = _pin_truth(_select(session), pin_secret)
2317
+ if verdict == "mismatch":
2318
+ raise CryptnoxError(
2319
+ f"the PIN is set and the provided PIN does not match ({tries} tries "
2320
+ "left). This pre-flight check consumed one retry - the certificate/"
2321
+ "smoke-test step would have consumed it otherwise. Provide the card's "
2322
+ "PIN (CRYPTNOX_PIV_PIN or the prompt) and re-run."
2323
+ )
2324
+ if verdict == "empty":
2325
+ app.out.warn(
2326
+ "the PIN verifier exists but holds no value (structure loaded without "
2327
+ "personalization) - the provided PIN will be SET; the PUK likewise."
2328
+ )
2329
+ steps = _apply_pin_truth(steps, verdict)
2330
+ run_set = {s.step for s in steps if s.run}
2331
+
2332
+ to_run = [s.step for s in steps if s.run]
2333
+ app.out.warn(f"quickstart will run: {', '.join(to_run)}")
2334
+ if steps[0].run:
2335
+ app.out.warn(
2336
+ "--include-preperso lays down the applet structure (PUT DATA ADMIN) - "
2337
+ "a manufacturing-style step intended for dev/eval cards."
2338
+ )
2339
+ if not app.yes and not click.confirm("Proceed?", default=False):
2340
+ raise click.Abort()
2341
+
2342
+ puk_secret = (
2343
+ resolve_secret(
2344
+ redactor=app.redactor, env_var="CRYPTNOX_PIV_NEW_PUK", prompt_label="New PIV PUK"
2345
+ )
2346
+ if "set-puk" in run_set
2347
+ else None
2348
+ )
2349
+
2350
+ adm = PivAdmin(session)
2351
+ executed: list[dict[str, object]] = []
2352
+ outputs: dict[str, str | None] = {"public_key": None, "certificate": None, "csr": None}
2353
+ spki: bytes | None = None
2354
+ failed = False
2355
+
2356
+ def record(step: str, status: str, **extra: object) -> None:
2357
+ executed.append({"step": step, "status": status, **extra})
2358
+
2359
+ for planned_step in steps:
2360
+ if not planned_step.run:
2361
+ record(planned_step.step, "skipped", reason=planned_step.reason)
2362
+ continue
2363
+ try:
2364
+ if planned_step.step == "preperso-load-config":
2365
+ for label, op in profile.build_ops():
2366
+ adm.select()
2367
+ adm.open(keys)
2368
+ resp = adm.send(
2369
+ preperso_mod.put_data_admin_apdu(preperso_mod.build_bulk([op])),
2370
+ context=f"PUT DATA ADMIN [{label}]",
2371
+ )
2372
+ if not resp.ok:
2373
+ raise StatusWordError(resp.sw1, resp.sw2, context=f"preperso [{label}]")
2374
+ record(planned_step.step, "ok", detail={"profile": profile.name})
2375
+ elif planned_step.step == "set-pin":
2376
+ padded = perso_mod.pad_pin(pin_secret)
2377
+ app.redactor.register(padded)
2378
+ resp = _set_verifier(adm, keys, pivc.REF_PIV_PIN, padded, label="PIN")
2379
+ if not resp.ok:
2380
+ raise StatusWordError(resp.sw1, resp.sw2, context="SET PIN")
2381
+ record(planned_step.step, "ok", sw=resp.sw_hex())
2382
+ elif planned_step.step == "set-puk":
2383
+ if puk_secret is None: # unreachable: planner gates set-puk on a resolved PUK
2384
+ raise RuntimeError("set-puk planned without a PUK value")
2385
+ padded = perso_mod.pad_pin(puk_secret)
2386
+ app.redactor.register(padded)
2387
+ resp = _set_verifier(adm, keys, pivc.REF_PUK, padded, label="PUK")
2388
+ if not resp.ok:
2389
+ raise StatusWordError(resp.sw1, resp.sw2, context="SET PUK")
2390
+ record(planned_step.step, "ok", sw=resp.sw_hex())
2391
+ elif planned_step.step == "generate-key":
2392
+ public_key = _generate_key_on_card(adm, keys, ref, mech, label=slot)
2393
+ spki = public_key.public_bytes(
2394
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
2395
+ )
2396
+ fingerprint = hashlib.sha256(spki).hexdigest().upper()
2397
+ if public_out:
2398
+ Path(public_out).write_bytes(perso_mod.public_key_pem(public_key))
2399
+ outputs["public_key"] = public_out
2400
+ record(
2401
+ planned_step.step,
2402
+ "ok",
2403
+ detail={"algorithm": alg, "public_key_sha256": fingerprint},
2404
+ )
2405
+ elif planned_step.step == "certificate":
2406
+ if spki is None: # unreachable: generate-key runs whenever certificate runs
2407
+ raise RuntimeError("certificate step planned without a generated key")
2408
+ spki_bytes = spki
2409
+ if cert_mode == "csr":
2410
+ pem = _sign_with_session(
2411
+ session,
2412
+ ref,
2413
+ mech,
2414
+ pin_secret,
2415
+ lambda s, _spki=spki_bytes: csr_mod.build_csr(subject, _spki, mech, s),
2416
+ )
2417
+ if csr_out is None: # unreachable: csr mode validates --csr-out at parse
2418
+ raise RuntimeError("csr mode without --csr-out")
2419
+ Path(csr_out).write_bytes(pem)
2420
+ outputs["csr"] = csr_out
2421
+ record(
2422
+ planned_step.step,
2423
+ "ok",
2424
+ detail={
2425
+ "mode": "csr",
2426
+ "note": "re-running quickstart regenerates the key and "
2427
+ "invalidates this CSR",
2428
+ },
2429
+ )
2430
+ else:
2431
+ serial = int.from_bytes(pysecrets.token_bytes(8), "big") | 1
2432
+ not_before = datetime.datetime.now(datetime.timezone.utc).replace(
2433
+ microsecond=0
2434
+ )
2435
+ not_after = not_before + datetime.timedelta(days=days)
2436
+
2437
+ def _build_self_signed(
2438
+ s,
2439
+ _spki=spki_bytes,
2440
+ _serial=serial,
2441
+ _nb=not_before.isoformat(),
2442
+ _na=not_after.isoformat(),
2443
+ ):
2444
+ return csr_mod.build_self_signed(
2445
+ subject,
2446
+ _spki,
2447
+ mech,
2448
+ s,
2449
+ serial=_serial,
2450
+ not_before=_nb,
2451
+ not_after=_na,
2452
+ )
2453
+
2454
+ pem = _sign_with_session(session, ref, mech, pin_secret, _build_self_signed)
2455
+ cert_der = x509.load_pem_x509_certificate(pem).public_bytes(
2456
+ serialization.Encoding.DER
2457
+ )
2458
+ resp = _import_cert_der(adm, keys, ref, cert_der, label=slot)
2459
+ if not resp.ok:
2460
+ raise StatusWordError(resp.sw1, resp.sw2, context="IMPORT cert")
2461
+ if cert_out:
2462
+ Path(cert_out).write_bytes(pem)
2463
+ outputs["certificate"] = cert_out
2464
+ record(
2465
+ planned_step.step,
2466
+ "ok",
2467
+ detail={"mode": "self-signed", "subject": subject, "days": days},
2468
+ )
2469
+ elif planned_step.step in ("write-chuid", "write-ccc"):
2470
+ name = "chuid" if planned_step.step == "write-chuid" else "ccc"
2471
+ results = _write_standard(adm, keys, (name,))
2472
+ if not results[0]["ok"]:
2473
+ raise StatusWordError(
2474
+ int(str(results[0]["sw"])[:2], 16),
2475
+ int(str(results[0]["sw"])[2:], 16),
2476
+ context=f"PUT DATA {name}",
2477
+ )
2478
+ record(planned_step.step, "ok", sw=results[0]["sw"])
2479
+ elif planned_step.step == "smoke-test":
2480
+ digest = os.urandom(keyimport.digest_for_mechanism(mech).digest_size)
2481
+ try:
2482
+ signature = _sign_with_session(
2483
+ session, ref, mech, pin_secret, lambda s, _d=digest: s(_d)
2484
+ )
2485
+ record(planned_step.step, "ok", detail={"signature_len": len(signature)})
2486
+ except StatusWordError as exc:
2487
+ if exc.info.sw_hex() == "6985":
2488
+ record(
2489
+ planned_step.step,
2490
+ "ok",
2491
+ detail={"note": "slot role cannot sign on this applet"},
2492
+ )
2493
+ else:
2494
+ raise
2495
+ except CryptnoxError as exc:
2496
+ record(planned_step.step, "failed", reason=str(exc))
2497
+ failed = True
2498
+ break
2499
+
2500
+ try:
2501
+ _, state_after = _collect_quickstart_facts(session, ref, mech)
2502
+ except CryptnoxError:
2503
+ state_after = "unknown"
2504
+
2505
+ reader = app.resolved_reader or "<your contact reader>"
2506
+ slot_lc = slot.lower()
2507
+ next_cmds = [
2508
+ f'yubico-piv-tool -r "{reader}" -a status',
2509
+ f'yubico-piv-tool -r "{reader}" -a read-certificate -s {slot_lc} -o {slot_lc}.crt.pem',
2510
+ f'yubico-piv-tool -r "{reader}" -a verify-pin',
2511
+ ]
2512
+ payload = {
2513
+ "quickstart": True,
2514
+ "dry_run": False,
2515
+ "slot": slot,
2516
+ "algorithm": alg,
2517
+ "cert_mode": cert_mode,
2518
+ "preperso_profile": profile.name,
2519
+ "state_before": state_before,
2520
+ "steps": executed,
2521
+ "state_after": state_after,
2522
+ "outputs": outputs,
2523
+ "next": next_cmds if not failed else [],
2524
+ }
2525
+
2526
+ def human(c: Console) -> None:
2527
+ table = app.out.table("Step", "Result", "Note")
2528
+ for e in executed:
2529
+ mark = {
2530
+ "ok": "[green]ok[/green]",
2531
+ "skipped": "[dim]skipped[/dim]",
2532
+ "failed": "[red]failed[/red]",
2533
+ }[str(e["status"])]
2534
+ note = e.get("reason") or e.get("sw") or ""
2535
+ detail = e.get("detail")
2536
+ if isinstance(detail, dict) and detail.get("note"):
2537
+ note = detail["note"]
2538
+ table.add_row(str(e["step"]), mark, str(note))
2539
+ c.print(table)
2540
+ c.print(f"\n Card state: {state_before} -> {state_after}")
2541
+ if failed:
2542
+ c.print(
2543
+ "[red]quickstart stopped at the first failure[/red]; completed steps are "
2544
+ "kept - fix the cause and re-run (it skips what is already done)."
2545
+ )
2546
+ return
2547
+ c.print("\n[bold]Verify with standard PIV tooling:[/bold]")
2548
+ for cmd_line in next_cmds:
2549
+ c.print(f" {cmd_line}")
2550
+ c.print(
2551
+ "[dim]Yubico-proprietary actions (version, attest, set-mgm-key, and writes via "
2552
+ "yubico-piv-tool) do not apply: this card administers over SCP03 via "
2553
+ f"{CLI_NAME}.[/dim]"
2554
+ )
2555
+
2556
+ app.out.result(payload, human)
2557
+ if failed:
2558
+ sys.exit(6)