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.
- cryptnox_id_cli/__init__.py +22 -0
- cryptnox_id_cli/__main__.py +6 -0
- cryptnox_id_cli/applets/__init__.py +2 -0
- cryptnox_id_cli/applets/fido/__init__.py +7 -0
- cryptnox_id_cli/applets/fido/authdata.py +81 -0
- cryptnox_id_cli/applets/fido/constants.py +155 -0
- cryptnox_id_cli/applets/fido/ctap.py +430 -0
- cryptnox_id_cli/applets/fido/errors.py +90 -0
- cryptnox_id_cli/applets/fido/pinproto.py +166 -0
- cryptnox_id_cli/applets/genuine/__init__.py +12 -0
- cryptnox_id_cli/applets/genuine/constants.py +32 -0
- cryptnox_id_cli/applets/genuine/genuine.py +85 -0
- cryptnox_id_cli/applets/genuine/verify.py +150 -0
- cryptnox_id_cli/applets/mifare/__init__.py +10 -0
- cryptnox_id_cli/applets/mifare/desfire.py +353 -0
- cryptnox_id_cli/applets/mifare/ev2.py +421 -0
- cryptnox_id_cli/applets/piv/__init__.py +9 -0
- cryptnox_id_cli/applets/piv/admin.py +182 -0
- cryptnox_id_cli/applets/piv/apt.py +58 -0
- cryptnox_id_cli/applets/piv/constants.py +66 -0
- cryptnox_id_cli/applets/piv/keyimport.py +244 -0
- cryptnox_id_cli/applets/piv/objects.py +126 -0
- cryptnox_id_cli/applets/piv/perso.py +138 -0
- cryptnox_id_cli/applets/piv/piv.py +171 -0
- cryptnox_id_cli/applets/piv/preperso.py +174 -0
- cryptnox_id_cli/applets/piv/profiles.py +486 -0
- cryptnox_id_cli/applets/piv/slots.py +44 -0
- cryptnox_id_cli/cli/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/apdu.py +123 -0
- cryptnox_id_cli/cli/commands/doctor.py +144 -0
- cryptnox_id_cli/cli/commands/factory.py +312 -0
- cryptnox_id_cli/cli/commands/fido.py +729 -0
- cryptnox_id_cli/cli/commands/genuine.py +192 -0
- cryptnox_id_cli/cli/commands/info.py +94 -0
- cryptnox_id_cli/cli/commands/mifare.py +998 -0
- cryptnox_id_cli/cli/commands/piv.py +2558 -0
- cryptnox_id_cli/cli/commands/readers.py +64 -0
- cryptnox_id_cli/cli/commands/report.py +217 -0
- cryptnox_id_cli/cli/commands/shell.py +130 -0
- cryptnox_id_cli/cli/context.py +71 -0
- cryptnox_id_cli/cli/dryrun.py +181 -0
- cryptnox_id_cli/cli/main.py +117 -0
- cryptnox_id_cli/crypto/__init__.py +1 -0
- cryptnox_id_cli/crypto/attestation.py +169 -0
- cryptnox_id_cli/crypto/csr.py +135 -0
- cryptnox_id_cli/crypto/piv_objects.py +79 -0
- cryptnox_id_cli/crypto/x509util.py +37 -0
- cryptnox_id_cli/output/__init__.py +5 -0
- cryptnox_id_cli/output/render.py +106 -0
- cryptnox_id_cli/secrets/__init__.py +5 -0
- cryptnox_id_cli/secrets/redaction.py +141 -0
- cryptnox_id_cli/secrets/resolver.py +93 -0
- cryptnox_id_cli/state/__init__.py +19 -0
- cryptnox_id_cli/state/detector.py +273 -0
- cryptnox_id_cli/state/model.py +147 -0
- cryptnox_id_cli/transport/__init__.py +28 -0
- cryptnox_id_cli/transport/apdu.py +72 -0
- cryptnox_id_cli/transport/elevation.py +165 -0
- cryptnox_id_cli/transport/errors.py +185 -0
- cryptnox_id_cli/transport/pcsc.py +351 -0
- cryptnox_id_cli/transport/scp02.py +247 -0
- cryptnox_id_cli/transport/scp03.py +213 -0
- cryptnox_id_cli/trust/__init__.py +90 -0
- cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
- cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
- cryptnox_id_cli/util/__init__.py +1 -0
- cryptnox_id_cli/util/hexutil.py +36 -0
- cryptnox_id_cli/util/tlv.py +144 -0
- cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
- cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
- cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
- cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
- cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
- 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)
|