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,486 @@
|
|
|
1
|
+
"""PIV pre-personalization profiles: a validated model that compiles to a BULK
|
|
2
|
+
PUT DATA ADMIN payload. ``cryptnox-default`` reproduces the applet's own reference
|
|
3
|
+
profile byte-for-byte.
|
|
4
|
+
|
|
5
|
+
Profiles are user-facing YAML (names, not magic numbers); the model resolves names
|
|
6
|
+
to the applet's byte values and validates against what THIS applet supports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from cryptnox_id_cli.applets.piv import constants as c
|
|
17
|
+
from cryptnox_id_cli.applets.piv import preperso as pp
|
|
18
|
+
from cryptnox_id_cli.transport.errors import CryptnoxError
|
|
19
|
+
|
|
20
|
+
# Name <-> byte maps (user-friendly YAML).
|
|
21
|
+
MECHANISMS = {
|
|
22
|
+
"AES128": 0x08,
|
|
23
|
+
"AES192": 0x0A,
|
|
24
|
+
"AES256": 0x0C,
|
|
25
|
+
"RSA2048": 0x07,
|
|
26
|
+
"RSA3072": 0x05,
|
|
27
|
+
"ECCP256": 0x11,
|
|
28
|
+
"ECCP384": 0x14,
|
|
29
|
+
"CS2": 0x27,
|
|
30
|
+
"CS7": 0x2E,
|
|
31
|
+
}
|
|
32
|
+
MODES = {
|
|
33
|
+
"NEVER": pp.MODE_NEVER,
|
|
34
|
+
"PIN": pp.MODE_PIN,
|
|
35
|
+
"OCC": pp.MODE_OCC,
|
|
36
|
+
"SM": pp.MODE_SM,
|
|
37
|
+
"VCI": pp.MODE_VCI,
|
|
38
|
+
"VCI_PIN": pp.MODE_VCI_PIN,
|
|
39
|
+
"ALWAYS": pp.MODE_ALWAYS,
|
|
40
|
+
}
|
|
41
|
+
ROLES = {
|
|
42
|
+
"AUTHENTICATE": pp.ROLE_AUTHENTICATE,
|
|
43
|
+
"KEY_ESTABLISH": pp.ROLE_KEY_ESTABLISH,
|
|
44
|
+
"SIGN": pp.ROLE_SIGN,
|
|
45
|
+
}
|
|
46
|
+
ATTRS = {
|
|
47
|
+
"PERMIT_EXTERNAL": pp.ATTR_PERMIT_EXTERNAL,
|
|
48
|
+
"PERMIT_MUTUAL": pp.ATTR_PERMIT_MUTUAL,
|
|
49
|
+
"IMPORTABLE": pp.ATTR_IMPORTABLE,
|
|
50
|
+
"RSA_CRT": pp.ATTR_RSA_CRT,
|
|
51
|
+
}
|
|
52
|
+
CHARSETS = {"numeric": 0x00, "alpha": 0x01, "alpha_invariant": 0x02, "raw": 0x03}
|
|
53
|
+
|
|
54
|
+
_REV_MECH = {v: k for k, v in MECHANISMS.items()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ProfileError(CryptnoxError):
|
|
58
|
+
"""Profile parse/validation failure (aggregates all messages)."""
|
|
59
|
+
|
|
60
|
+
code = "profile_error"
|
|
61
|
+
exit_code = 8
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PinPolicy:
|
|
66
|
+
min_length: int
|
|
67
|
+
max_length: int
|
|
68
|
+
retries: int
|
|
69
|
+
charset: int = 0x00
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ContainerDef:
|
|
74
|
+
oid: bytes
|
|
75
|
+
name: str
|
|
76
|
+
contact: int
|
|
77
|
+
contactless: int
|
|
78
|
+
admin_key: int = c.KEYREF_ADMIN
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class KeyDef:
|
|
83
|
+
ref: int
|
|
84
|
+
name: str
|
|
85
|
+
mechanism: int
|
|
86
|
+
role: int
|
|
87
|
+
contact: int
|
|
88
|
+
contactless: int
|
|
89
|
+
attributes: int
|
|
90
|
+
admin_key: int | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class PivProfile:
|
|
95
|
+
name: str
|
|
96
|
+
mode: str
|
|
97
|
+
admin_key_ref: int
|
|
98
|
+
admin_mechanism: int
|
|
99
|
+
pin: PinPolicy
|
|
100
|
+
puk: PinPolicy
|
|
101
|
+
containers: list[ContainerDef] = field(default_factory=list)
|
|
102
|
+
keys: list[KeyDef] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
# -- compile ------------------------------------------------------------ #
|
|
105
|
+
def build_ops(self) -> list[tuple[str, bytes]]:
|
|
106
|
+
"""Ordered (label, op-bytes): containers, PIN, PUK, then keys."""
|
|
107
|
+
ops: list[tuple[str, bytes]] = []
|
|
108
|
+
for ct in self.containers:
|
|
109
|
+
ops.append(
|
|
110
|
+
(
|
|
111
|
+
f"container {ct.name} {ct.oid.hex().upper()}",
|
|
112
|
+
pp.create_container(ct.oid, ct.contact, ct.contactless, admin_key=ct.admin_key),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
ops.append(
|
|
116
|
+
(
|
|
117
|
+
"verifier PIN (80)",
|
|
118
|
+
pp.create_verifier(
|
|
119
|
+
c.REF_PIV_PIN,
|
|
120
|
+
pp.MODE_ALWAYS,
|
|
121
|
+
pp.MODE_NEVER,
|
|
122
|
+
self.pin.min_length,
|
|
123
|
+
self.pin.max_length,
|
|
124
|
+
self.pin.retries,
|
|
125
|
+
0,
|
|
126
|
+
charset=self.pin.charset,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
ops.append(
|
|
131
|
+
(
|
|
132
|
+
"verifier PUK (81)",
|
|
133
|
+
pp.create_verifier(
|
|
134
|
+
c.REF_PUK,
|
|
135
|
+
pp.MODE_ALWAYS,
|
|
136
|
+
pp.MODE_NEVER,
|
|
137
|
+
self.puk.min_length,
|
|
138
|
+
self.puk.max_length,
|
|
139
|
+
self.puk.retries,
|
|
140
|
+
0,
|
|
141
|
+
charset=self.puk.charset,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
for k in self.keys:
|
|
146
|
+
ops.append(
|
|
147
|
+
(
|
|
148
|
+
f"key {k.name} ({k.ref:02X})",
|
|
149
|
+
pp.create_key(
|
|
150
|
+
k.ref,
|
|
151
|
+
k.contact,
|
|
152
|
+
k.contactless,
|
|
153
|
+
k.mechanism,
|
|
154
|
+
k.role,
|
|
155
|
+
k.attributes,
|
|
156
|
+
admin_key=k.admin_key,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return ops
|
|
161
|
+
|
|
162
|
+
def build_payload(self) -> bytes:
|
|
163
|
+
"""The full BULK PUT DATA ADMIN CDATA."""
|
|
164
|
+
return pp.build_bulk([op for _, op in self.build_ops()])
|
|
165
|
+
|
|
166
|
+
# -- validation --------------------------------------------------------- #
|
|
167
|
+
def validate(self) -> list[str]:
|
|
168
|
+
errors: list[str] = []
|
|
169
|
+
if self.admin_mechanism not in (0x08, 0x0A, 0x0C):
|
|
170
|
+
errors.append(
|
|
171
|
+
"admin key mechanism must be AES-128/192/256 (this applet: 9B is AES-only)"
|
|
172
|
+
)
|
|
173
|
+
for label, pol, floor in (("PIN", self.pin, 6), ("PUK", self.puk, 6)):
|
|
174
|
+
if pol.min_length < floor:
|
|
175
|
+
errors.append(f"{label} min_length {pol.min_length} < minimum {floor}")
|
|
176
|
+
if pol.max_length < pol.min_length:
|
|
177
|
+
errors.append(f"{label} max_length < min_length")
|
|
178
|
+
if not (0 <= pol.retries <= 10):
|
|
179
|
+
errors.append(f"{label} retries {pol.retries} out of range (max 10)")
|
|
180
|
+
for k in self.keys:
|
|
181
|
+
if k.ref == c.KEYREF_ADMIN:
|
|
182
|
+
continue # admin key checked above
|
|
183
|
+
if k.mechanism not in c.SUPPORTED_ALGORITHMS:
|
|
184
|
+
name = _REV_MECH.get(k.mechanism, hex(k.mechanism))
|
|
185
|
+
errors.append(f"key {k.ref:02X} mechanism {name} not supported by this applet")
|
|
186
|
+
if not self.containers and not self.keys:
|
|
187
|
+
errors.append("profile defines no containers or keys")
|
|
188
|
+
return errors
|
|
189
|
+
|
|
190
|
+
def ensure_valid(self) -> None:
|
|
191
|
+
errs = self.validate()
|
|
192
|
+
if errs:
|
|
193
|
+
raise ProfileError("; ".join(errs))
|
|
194
|
+
|
|
195
|
+
# -- (de)serialization -------------------------------------------------- #
|
|
196
|
+
def to_dict(self) -> dict[str, Any]:
|
|
197
|
+
def mode_name(v: int) -> str:
|
|
198
|
+
return next((n for n, x in MODES.items() if x == v), hex(v))
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"name": self.name,
|
|
202
|
+
"mode": self.mode,
|
|
203
|
+
"admin": {
|
|
204
|
+
"key_ref": f"{self.admin_key_ref:02X}",
|
|
205
|
+
"mechanism": _REV_MECH.get(self.admin_mechanism, hex(self.admin_mechanism)),
|
|
206
|
+
},
|
|
207
|
+
"pin": {
|
|
208
|
+
"min": self.pin.min_length,
|
|
209
|
+
"max": self.pin.max_length,
|
|
210
|
+
"retries": self.pin.retries,
|
|
211
|
+
"charset": next(
|
|
212
|
+
(n for n, x in CHARSETS.items() if x == self.pin.charset), self.pin.charset
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
"puk": {
|
|
216
|
+
"min": self.puk.min_length,
|
|
217
|
+
"max": self.puk.max_length,
|
|
218
|
+
"retries": self.puk.retries,
|
|
219
|
+
"charset": next(
|
|
220
|
+
(n for n, x in CHARSETS.items() if x == self.puk.charset), self.puk.charset
|
|
221
|
+
),
|
|
222
|
+
},
|
|
223
|
+
"containers": [
|
|
224
|
+
{
|
|
225
|
+
"oid": ct.oid.hex().upper(),
|
|
226
|
+
"name": ct.name,
|
|
227
|
+
"contact": mode_name(ct.contact),
|
|
228
|
+
"contactless": mode_name(ct.contactless),
|
|
229
|
+
}
|
|
230
|
+
for ct in self.containers
|
|
231
|
+
],
|
|
232
|
+
"keys": [
|
|
233
|
+
{
|
|
234
|
+
"ref": f"{k.ref:02X}",
|
|
235
|
+
"name": k.name,
|
|
236
|
+
"mechanism": _REV_MECH.get(k.mechanism, hex(k.mechanism)),
|
|
237
|
+
"role": _role_name(k.role),
|
|
238
|
+
"contact": mode_name(k.contact),
|
|
239
|
+
"contactless": mode_name(k.contactless),
|
|
240
|
+
"attributes": [n for n, x in ATTRS.items() if k.attributes & x],
|
|
241
|
+
}
|
|
242
|
+
for k in self.keys
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
def to_yaml(self) -> str:
|
|
247
|
+
return yaml.safe_dump(self.to_dict(), sort_keys=False)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _mode(v: Any) -> int:
|
|
251
|
+
if isinstance(v, int):
|
|
252
|
+
return v
|
|
253
|
+
key = str(v).upper()
|
|
254
|
+
if key not in MODES:
|
|
255
|
+
raise ProfileError(f"unknown access mode {v!r}")
|
|
256
|
+
return MODES[key]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _role(v: Any) -> int:
|
|
260
|
+
"""Parse a key role: one name, a '+'-combined string ('SIGN+AUTHENTICATE'),
|
|
261
|
+
a list of names, or a raw int. The applet tests roles as a bitmask."""
|
|
262
|
+
if isinstance(v, int):
|
|
263
|
+
return v
|
|
264
|
+
parts = [str(p) for p in v] if isinstance(v, (list, tuple)) else str(v).split("+")
|
|
265
|
+
out = 0
|
|
266
|
+
for part in parts:
|
|
267
|
+
key = part.strip().upper()
|
|
268
|
+
if key not in ROLES:
|
|
269
|
+
raise ProfileError(f"unknown key role {part.strip()!r}")
|
|
270
|
+
out |= ROLES[key]
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _role_name(v: int) -> str:
|
|
275
|
+
exact = next((n for n, x in ROLES.items() if x == v), None)
|
|
276
|
+
if exact:
|
|
277
|
+
return exact
|
|
278
|
+
bits = [n for n, x in ROLES.items() if v & x]
|
|
279
|
+
if bits and sum(ROLES[n] for n in bits) == v:
|
|
280
|
+
return "+".join(bits)
|
|
281
|
+
return hex(v)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _attrs(values: Any) -> int:
|
|
285
|
+
out = 0
|
|
286
|
+
for a in values or []:
|
|
287
|
+
key = str(a).upper()
|
|
288
|
+
if key not in ATTRS:
|
|
289
|
+
raise ProfileError(f"unknown key attribute {a!r}")
|
|
290
|
+
out |= ATTRS[key]
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def from_dict(d: dict[str, Any]) -> PivProfile:
|
|
295
|
+
try:
|
|
296
|
+
admin = d["admin"]
|
|
297
|
+
pin, puk = d["pin"], d["puk"]
|
|
298
|
+
prof = PivProfile(
|
|
299
|
+
name=str(d["name"]),
|
|
300
|
+
mode=str(d.get("mode", "production")),
|
|
301
|
+
admin_key_ref=int(str(admin["key_ref"]), 16),
|
|
302
|
+
admin_mechanism=MECHANISMS[str(admin["mechanism"]).upper()],
|
|
303
|
+
pin=PinPolicy(
|
|
304
|
+
int(pin["min"]),
|
|
305
|
+
int(pin["max"]),
|
|
306
|
+
int(pin["retries"]),
|
|
307
|
+
CHARSETS.get(str(pin.get("charset", "numeric")), 0x00),
|
|
308
|
+
),
|
|
309
|
+
puk=PinPolicy(
|
|
310
|
+
int(puk["min"]),
|
|
311
|
+
int(puk["max"]),
|
|
312
|
+
int(puk["retries"]),
|
|
313
|
+
CHARSETS.get(str(puk.get("charset", "numeric")), 0x00),
|
|
314
|
+
),
|
|
315
|
+
containers=[
|
|
316
|
+
ContainerDef(
|
|
317
|
+
bytes.fromhex(str(ct["oid"])),
|
|
318
|
+
str(ct.get("name", "")),
|
|
319
|
+
_mode(ct["contact"]),
|
|
320
|
+
_mode(ct["contactless"]),
|
|
321
|
+
)
|
|
322
|
+
for ct in d.get("containers", [])
|
|
323
|
+
],
|
|
324
|
+
keys=[
|
|
325
|
+
KeyDef(
|
|
326
|
+
int(str(k["ref"]), 16),
|
|
327
|
+
str(k.get("name", "")),
|
|
328
|
+
MECHANISMS[str(k["mechanism"]).upper()],
|
|
329
|
+
_role(k["role"]),
|
|
330
|
+
_mode(k["contact"]),
|
|
331
|
+
_mode(k["contactless"]),
|
|
332
|
+
_attrs(k.get("attributes")),
|
|
333
|
+
admin_key=int(str(k["admin_key"]), 16) if k.get("admin_key") else None,
|
|
334
|
+
)
|
|
335
|
+
for k in d.get("keys", [])
|
|
336
|
+
],
|
|
337
|
+
)
|
|
338
|
+
except (KeyError, ValueError) as exc:
|
|
339
|
+
raise ProfileError(f"invalid profile: {exc}") from exc
|
|
340
|
+
return prof
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def from_yaml(text: str) -> PivProfile:
|
|
344
|
+
data = yaml.safe_load(text)
|
|
345
|
+
if not isinstance(data, dict):
|
|
346
|
+
raise ProfileError("profile must be a YAML mapping")
|
|
347
|
+
return from_dict(data)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# --------------------------------------------------------------------------- #
|
|
351
|
+
# Built-in profiles #
|
|
352
|
+
# --------------------------------------------------------------------------- #
|
|
353
|
+
def _cryptnox_default() -> PivProfile:
|
|
354
|
+
"""Byte-exact to the applet's own reference profile (AES-256 admin, ECC-P256)."""
|
|
355
|
+
A = pp.MODE_ALWAYS
|
|
356
|
+
N = pp.MODE_NEVER
|
|
357
|
+
P = pp.MODE_PIN
|
|
358
|
+
VCI = pp.MODE_VCI
|
|
359
|
+
VCIP = pp.MODE_VCI_PIN
|
|
360
|
+
containers = [
|
|
361
|
+
ContainerDef(bytes.fromhex("5FC102"), "chuid", A, A),
|
|
362
|
+
ContainerDef(bytes.fromhex("5FC107"), "ccc", A, VCI),
|
|
363
|
+
ContainerDef(bytes.fromhex("5FC105"), "auth-cert", A, VCI),
|
|
364
|
+
ContainerDef(bytes.fromhex("5FC101"), "card-auth-cert", A, A),
|
|
365
|
+
ContainerDef(bytes.fromhex("5FC10A"), "sign-cert", A, VCI),
|
|
366
|
+
ContainerDef(bytes.fromhex("5FC10B"), "keymgmt-cert", A, VCI),
|
|
367
|
+
ContainerDef(bytes.fromhex("5FC106"), "security-object", A, VCI),
|
|
368
|
+
ContainerDef(bytes.fromhex("5FC103"), "fingerprints", P, VCIP),
|
|
369
|
+
ContainerDef(bytes.fromhex("5FC108"), "facial", P, VCIP),
|
|
370
|
+
ContainerDef(bytes.fromhex("5FC109"), "printed", P, VCIP),
|
|
371
|
+
]
|
|
372
|
+
keys = [
|
|
373
|
+
KeyDef(
|
|
374
|
+
c.KEYREF_ADMIN,
|
|
375
|
+
"admin",
|
|
376
|
+
0x0C,
|
|
377
|
+
pp.ROLE_AUTHENTICATE,
|
|
378
|
+
A,
|
|
379
|
+
N,
|
|
380
|
+
pp.ATTR_IMPORTABLE | pp.ATTR_PERMIT_MUTUAL | pp.ATTR_PERMIT_EXTERNAL,
|
|
381
|
+
),
|
|
382
|
+
KeyDef(c.KEYREF_PIV_AUTH, "auth", 0x11, pp.ROLE_AUTHENTICATE, P, N, pp.ATTR_IMPORTABLE),
|
|
383
|
+
KeyDef(c.KEYREF_DIGITAL_SIGNATURE, "sign", 0x11, pp.ROLE_SIGN, P, N, pp.ATTR_IMPORTABLE),
|
|
384
|
+
KeyDef(
|
|
385
|
+
c.KEYREF_KEY_MANAGEMENT,
|
|
386
|
+
"keymgmt",
|
|
387
|
+
0x11,
|
|
388
|
+
pp.ROLE_KEY_ESTABLISH,
|
|
389
|
+
P,
|
|
390
|
+
N,
|
|
391
|
+
pp.ATTR_IMPORTABLE,
|
|
392
|
+
),
|
|
393
|
+
KeyDef(
|
|
394
|
+
c.KEYREF_CARD_AUTH, "card-auth", 0x11, pp.ROLE_AUTHENTICATE, A, A, pp.ATTR_IMPORTABLE
|
|
395
|
+
),
|
|
396
|
+
]
|
|
397
|
+
return PivProfile(
|
|
398
|
+
name="cryptnox-default",
|
|
399
|
+
mode="production",
|
|
400
|
+
admin_key_ref=c.KEYREF_ADMIN,
|
|
401
|
+
admin_mechanism=0x0C,
|
|
402
|
+
pin=PinPolicy(6, 8, 6, 0x00),
|
|
403
|
+
puk=PinPolicy(8, 8, 6, 0x00),
|
|
404
|
+
containers=containers,
|
|
405
|
+
keys=keys,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _developer() -> PivProfile:
|
|
410
|
+
prof = _cryptnox_default()
|
|
411
|
+
prof.name = "developer"
|
|
412
|
+
prof.mode = "developer-not-for-production"
|
|
413
|
+
return prof
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _npivp_lab() -> PivProfile:
|
|
417
|
+
prof = _cryptnox_default()
|
|
418
|
+
prof.name = "npivp-lab"
|
|
419
|
+
prof.mode = "lab"
|
|
420
|
+
return prof
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _ms_logon() -> PivProfile:
|
|
424
|
+
"""Windows smart-card logon / Remote Desktop. This applet dispatches PKI
|
|
425
|
+
challenge signing on ROLE_SIGN only, so the 9A (PIV Authentication) keys get
|
|
426
|
+
SIGN+AUTHENTICATE — that is what makes client authentication (PKINIT, SSH,
|
|
427
|
+
TLS) and on-card CSR generation work on 9A. A second, importable RSA-2048
|
|
428
|
+
object coexists on 9A for AD-issued credentials (CSR -> CA -> import, or
|
|
429
|
+
PKCS#12 import). The role must NOT include KEY_ESTABLISH: the applet routes
|
|
430
|
+
challenge-response to key transport before signing."""
|
|
431
|
+
prof = _cryptnox_default()
|
|
432
|
+
prof.name = "ms-logon"
|
|
433
|
+
auth_role = pp.ROLE_SIGN | pp.ROLE_AUTHENTICATE
|
|
434
|
+
for k in prof.keys:
|
|
435
|
+
if k.ref == c.KEYREF_PIV_AUTH:
|
|
436
|
+
k.role = auth_role
|
|
437
|
+
prof.keys.append(
|
|
438
|
+
KeyDef(
|
|
439
|
+
c.KEYREF_PIV_AUTH,
|
|
440
|
+
"auth-rsa",
|
|
441
|
+
MECHANISMS["RSA2048"],
|
|
442
|
+
auth_role,
|
|
443
|
+
pp.MODE_PIN,
|
|
444
|
+
pp.MODE_NEVER,
|
|
445
|
+
pp.ATTR_IMPORTABLE | pp.ATTR_RSA_CRT,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
return prof
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _ssh() -> PivProfile:
|
|
452
|
+
"""SSH auth (raw public key or SSH user certificates) via PKCS#11
|
|
453
|
+
(``ssh-agent`` / OpenSC), on either path. 9A (PIV Authentication) gets
|
|
454
|
+
SIGN added to its role -- the applet only dispatches challenge-response
|
|
455
|
+
signing on ROLE_SIGN, so plain AUTHENTICATE-only 9A cannot produce the
|
|
456
|
+
signature SSH needs. 9C cannot substitute: OpenSC's PIV driver treats key
|
|
457
|
+
reference 0x9C as requiring re-authentication before every signature
|
|
458
|
+
(PKCS#11 CKA_ALWAYS_AUTHENTICATE, matching the PIV spec's non-repudiation
|
|
459
|
+
convention for that slot), which neither ssh-agent nor ssh's own PKCS#11
|
|
460
|
+
client can satisfy. Unlike ms-logon, no second RSA object is added on 9A:
|
|
461
|
+
there is no AD-issued-credential path to support here."""
|
|
462
|
+
prof = _cryptnox_default()
|
|
463
|
+
prof.name = "ssh"
|
|
464
|
+
for k in prof.keys:
|
|
465
|
+
if k.ref == c.KEYREF_PIV_AUTH:
|
|
466
|
+
k.role = pp.ROLE_SIGN | pp.ROLE_AUTHENTICATE
|
|
467
|
+
return prof
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
_BUILTINS = {
|
|
471
|
+
"cryptnox-default": _cryptnox_default,
|
|
472
|
+
"npivp-lab": _npivp_lab,
|
|
473
|
+
"developer": _developer,
|
|
474
|
+
"ssh": _ssh,
|
|
475
|
+
"ms-logon": _ms_logon,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def builtin(name: str) -> PivProfile:
|
|
480
|
+
if name not in _BUILTINS:
|
|
481
|
+
raise ProfileError(f"unknown built-in profile {name!r}; choose from {', '.join(_BUILTINS)}")
|
|
482
|
+
return _BUILTINS[name]()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def builtin_names() -> list[str]:
|
|
486
|
+
return list(_BUILTINS)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""PIV key-reference (slot) registry for this applet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from cryptnox_id_cli.applets.piv import constants as c
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class PivSlot:
|
|
12
|
+
ref: int
|
|
13
|
+
name: str
|
|
14
|
+
usage: str
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def ref_hex(self) -> str:
|
|
18
|
+
return f"{self.ref:02X}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PIV_SLOTS: list[PivSlot] = [
|
|
22
|
+
PivSlot(c.KEYREF_PIV_AUTH, "authentication", "PIV authentication (9A)"),
|
|
23
|
+
PivSlot(c.KEYREF_DIGITAL_SIGNATURE, "digital_signature", "Document signing (9C)"),
|
|
24
|
+
PivSlot(c.KEYREF_KEY_MANAGEMENT, "key_management", "Key management / decryption (9D)"),
|
|
25
|
+
PivSlot(c.KEYREF_CARD_AUTH, "card_authentication", "Card authentication (9E)"),
|
|
26
|
+
PivSlot(c.KEYREF_ADMIN, "management", "Management/admin key, AES only (9B)"),
|
|
27
|
+
PivSlot(c.KEYREF_SECURE_MESSAGING, "secure_messaging", "PIV secure messaging (04)"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Retired key-management slots 82..95.
|
|
31
|
+
PIV_SLOTS += [
|
|
32
|
+
PivSlot(
|
|
33
|
+
ref,
|
|
34
|
+
f"retired_{ref - c.KEYREF_RETIRED_FIRST + 1:02d}",
|
|
35
|
+
f"Retired key management ({ref:02X})",
|
|
36
|
+
)
|
|
37
|
+
for ref in range(c.KEYREF_RETIRED_FIRST, c.KEYREF_RETIRED_LAST + 1)
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
SLOTS_BY_REF: dict[int, PivSlot] = {s.ref: s for s in PIV_SLOTS}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def slot_by_ref(ref: int) -> PivSlot | None:
|
|
44
|
+
return SLOTS_BY_REF.get(ref)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface (click)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command groups."""
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""``apdu`` — low-level APDU access (DEVELOPERS ONLY)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from cryptnox_id_cli.cli.context import AppContext
|
|
12
|
+
from cryptnox_id_cli.transport.apdu import APDU
|
|
13
|
+
from cryptnox_id_cli.transport.errors import describe_sw
|
|
14
|
+
from cryptnox_id_cli.util.hexutil import from_hex
|
|
15
|
+
|
|
16
|
+
_WARN = "low-level APDU use can change card state - use with care."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group("apdu")
|
|
20
|
+
def command() -> None:
|
|
21
|
+
"""Send raw APDUs. DEVELOPERS ONLY - secrets are redacted in logs."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dry_run(app: AppContext, commands: list[bytes]) -> None:
|
|
25
|
+
"""Honor the global --dry-run: show the (redacted) APDUs without transmitting."""
|
|
26
|
+
shown = [app.redactor.redact_command(c) for c in commands]
|
|
27
|
+
|
|
28
|
+
def human(c: Console) -> None:
|
|
29
|
+
for s in shown:
|
|
30
|
+
c.print(f"[yellow]dry-run:[/yellow] would send {s}")
|
|
31
|
+
|
|
32
|
+
app.out.result({"dry_run": True, "would_send": shown}, human)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ins(command_bytes: bytes) -> int:
|
|
36
|
+
"""The command INS, so response redaction can apply the same INS-aware policy
|
|
37
|
+
as the transcript layer (GENERAL AUTHENTICATE responses can carry key material)."""
|
|
38
|
+
return command_bytes[1] if len(command_bytes) >= 2 else -1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _show(app: AppContext, command_bytes: bytes, resp_data: bytes, sw1: int, sw2: int) -> None:
|
|
42
|
+
info = describe_sw(sw1, sw2)
|
|
43
|
+
payload = {
|
|
44
|
+
"command": app.redactor.redact_command(command_bytes),
|
|
45
|
+
"sw": info.sw_hex(),
|
|
46
|
+
"sw_name": info.name,
|
|
47
|
+
"sw_message": info.message,
|
|
48
|
+
"data": app.redactor.redact_response_data(resp_data, ins=_ins(command_bytes)),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def human(c: Console) -> None:
|
|
52
|
+
c.print(f"SW: [bold]{info.sw_hex()}[/bold] {info.name} - {info.message}")
|
|
53
|
+
c.print(f"Data: {payload['data'] or '-'}")
|
|
54
|
+
|
|
55
|
+
app.out.result(payload, human)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@command.command("send")
|
|
59
|
+
@click.option("--hex", "hex_", required=True, metavar="HEX", help="Full command APDU as hex.")
|
|
60
|
+
@click.pass_obj
|
|
61
|
+
def send(app: AppContext, hex_: str) -> None:
|
|
62
|
+
"""Send a raw command APDU."""
|
|
63
|
+
app.out.warn(_WARN)
|
|
64
|
+
raw = from_hex(hex_)
|
|
65
|
+
if app.dry_run:
|
|
66
|
+
_dry_run(app, [bytes(raw)])
|
|
67
|
+
return
|
|
68
|
+
with app.open_session() as session:
|
|
69
|
+
resp = session.transmit(bytes(raw))
|
|
70
|
+
_show(app, bytes(raw), resp.data, resp.sw1, resp.sw2)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@command.command("select")
|
|
74
|
+
@click.option("--aid", required=True, metavar="HEX", help="Applet AID as hex.")
|
|
75
|
+
@click.pass_obj
|
|
76
|
+
def select(app: AppContext, aid: str) -> None:
|
|
77
|
+
"""SELECT an applet by AID."""
|
|
78
|
+
app.out.warn(_WARN)
|
|
79
|
+
apdu = APDU(0x00, 0xA4, 0x04, 0x00, data=from_hex(aid), le=256)
|
|
80
|
+
if app.dry_run:
|
|
81
|
+
_dry_run(app, [apdu.to_bytes()])
|
|
82
|
+
return
|
|
83
|
+
with app.open_session() as session:
|
|
84
|
+
resp = session.transmit(apdu)
|
|
85
|
+
_show(app, apdu.to_bytes(), resp.data, resp.sw1, resp.sw2)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@command.command("transcript")
|
|
89
|
+
@click.option("--file", "file_", required=True, type=click.Path(exists=True, dir_okay=False))
|
|
90
|
+
@click.pass_obj
|
|
91
|
+
def transcript(app: AppContext, file_: str) -> None:
|
|
92
|
+
"""Replay a JSON array of hex command APDUs."""
|
|
93
|
+
app.out.warn(_WARN)
|
|
94
|
+
# utf-8-sig, not utf-8: PowerShell 5.1's `Set-Content -Encoding utf8` writes a BOM by
|
|
95
|
+
# default, and json.loads rejects it ("Unexpected UTF-8 BOM"). Decoding as utf-8-sig
|
|
96
|
+
# strips a BOM when present and is identical to utf-8 when it is not.
|
|
97
|
+
entries = _json.loads(Path(file_).read_text(encoding="utf-8-sig"))
|
|
98
|
+
if app.dry_run:
|
|
99
|
+
cmds = [from_hex(e if isinstance(e, str) else str(e.get("apdu", ""))) for e in entries]
|
|
100
|
+
_dry_run(app, [bytes(c) for c in cmds])
|
|
101
|
+
return
|
|
102
|
+
results = []
|
|
103
|
+
with app.open_session() as session:
|
|
104
|
+
for entry in entries:
|
|
105
|
+
hexstr = entry if isinstance(entry, str) else str(entry.get("apdu", ""))
|
|
106
|
+
raw = from_hex(hexstr)
|
|
107
|
+
resp = session.transmit(bytes(raw))
|
|
108
|
+
info = describe_sw(resp.sw1, resp.sw2)
|
|
109
|
+
results.append(
|
|
110
|
+
{
|
|
111
|
+
"command": app.redactor.redact_command(bytes(raw)),
|
|
112
|
+
"sw": info.sw_hex(),
|
|
113
|
+
"data": app.redactor.redact_response_data(resp.data, ins=_ins(bytes(raw))),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def human(c: Console) -> None:
|
|
118
|
+
table = app.out.table("#", "Command", "SW", "Data", title="APDU transcript")
|
|
119
|
+
for i, r in enumerate(results):
|
|
120
|
+
table.add_row(str(i), r["command"], r["sw"], r["data"] or "-")
|
|
121
|
+
c.print(table)
|
|
122
|
+
|
|
123
|
+
app.out.result({"results": results}, human)
|