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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. cryptnox_id_cli/__init__.py +22 -0
  2. cryptnox_id_cli/__main__.py +6 -0
  3. cryptnox_id_cli/applets/__init__.py +2 -0
  4. cryptnox_id_cli/applets/fido/__init__.py +7 -0
  5. cryptnox_id_cli/applets/fido/authdata.py +81 -0
  6. cryptnox_id_cli/applets/fido/constants.py +155 -0
  7. cryptnox_id_cli/applets/fido/ctap.py +430 -0
  8. cryptnox_id_cli/applets/fido/errors.py +90 -0
  9. cryptnox_id_cli/applets/fido/pinproto.py +166 -0
  10. cryptnox_id_cli/applets/genuine/__init__.py +12 -0
  11. cryptnox_id_cli/applets/genuine/constants.py +32 -0
  12. cryptnox_id_cli/applets/genuine/genuine.py +85 -0
  13. cryptnox_id_cli/applets/genuine/verify.py +150 -0
  14. cryptnox_id_cli/applets/mifare/__init__.py +10 -0
  15. cryptnox_id_cli/applets/mifare/desfire.py +353 -0
  16. cryptnox_id_cli/applets/mifare/ev2.py +421 -0
  17. cryptnox_id_cli/applets/piv/__init__.py +9 -0
  18. cryptnox_id_cli/applets/piv/admin.py +182 -0
  19. cryptnox_id_cli/applets/piv/apt.py +58 -0
  20. cryptnox_id_cli/applets/piv/constants.py +66 -0
  21. cryptnox_id_cli/applets/piv/keyimport.py +244 -0
  22. cryptnox_id_cli/applets/piv/objects.py +126 -0
  23. cryptnox_id_cli/applets/piv/perso.py +138 -0
  24. cryptnox_id_cli/applets/piv/piv.py +171 -0
  25. cryptnox_id_cli/applets/piv/preperso.py +174 -0
  26. cryptnox_id_cli/applets/piv/profiles.py +486 -0
  27. cryptnox_id_cli/applets/piv/slots.py +44 -0
  28. cryptnox_id_cli/cli/__init__.py +1 -0
  29. cryptnox_id_cli/cli/commands/__init__.py +1 -0
  30. cryptnox_id_cli/cli/commands/apdu.py +123 -0
  31. cryptnox_id_cli/cli/commands/doctor.py +144 -0
  32. cryptnox_id_cli/cli/commands/factory.py +312 -0
  33. cryptnox_id_cli/cli/commands/fido.py +729 -0
  34. cryptnox_id_cli/cli/commands/genuine.py +192 -0
  35. cryptnox_id_cli/cli/commands/info.py +94 -0
  36. cryptnox_id_cli/cli/commands/mifare.py +998 -0
  37. cryptnox_id_cli/cli/commands/piv.py +2558 -0
  38. cryptnox_id_cli/cli/commands/readers.py +64 -0
  39. cryptnox_id_cli/cli/commands/report.py +217 -0
  40. cryptnox_id_cli/cli/commands/shell.py +130 -0
  41. cryptnox_id_cli/cli/context.py +71 -0
  42. cryptnox_id_cli/cli/dryrun.py +181 -0
  43. cryptnox_id_cli/cli/main.py +117 -0
  44. cryptnox_id_cli/crypto/__init__.py +1 -0
  45. cryptnox_id_cli/crypto/attestation.py +169 -0
  46. cryptnox_id_cli/crypto/csr.py +135 -0
  47. cryptnox_id_cli/crypto/piv_objects.py +79 -0
  48. cryptnox_id_cli/crypto/x509util.py +37 -0
  49. cryptnox_id_cli/output/__init__.py +5 -0
  50. cryptnox_id_cli/output/render.py +106 -0
  51. cryptnox_id_cli/secrets/__init__.py +5 -0
  52. cryptnox_id_cli/secrets/redaction.py +141 -0
  53. cryptnox_id_cli/secrets/resolver.py +93 -0
  54. cryptnox_id_cli/state/__init__.py +19 -0
  55. cryptnox_id_cli/state/detector.py +273 -0
  56. cryptnox_id_cli/state/model.py +147 -0
  57. cryptnox_id_cli/transport/__init__.py +28 -0
  58. cryptnox_id_cli/transport/apdu.py +72 -0
  59. cryptnox_id_cli/transport/elevation.py +165 -0
  60. cryptnox_id_cli/transport/errors.py +185 -0
  61. cryptnox_id_cli/transport/pcsc.py +351 -0
  62. cryptnox_id_cli/transport/scp02.py +247 -0
  63. cryptnox_id_cli/transport/scp03.py +213 -0
  64. cryptnox_id_cli/trust/__init__.py +90 -0
  65. cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
  66. cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
  67. cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
  68. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
  69. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
  70. cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
  71. cryptnox_id_cli/util/__init__.py +1 -0
  72. cryptnox_id_cli/util/hexutil.py +36 -0
  73. cryptnox_id_cli/util/tlv.py +144 -0
  74. cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
  75. cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
  76. cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
  77. cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
  78. cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
  79. cryptnox_id_cli-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)