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,171 @@
1
+ """Read-only PIV applet operations over a :class:`CardSession`."""
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
+ from cryptnox_id_cli.applets.piv import objects as obj
9
+ from cryptnox_id_cli.applets.piv.apt import APTInfo, parse_apt
10
+ from cryptnox_id_cli.transport.apdu import APDU, Response
11
+ from cryptnox_id_cli.transport.errors import AppletNotFoundError, StatusWordError
12
+ from cryptnox_id_cli.transport.pcsc import CardSession
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PinStatus:
17
+ ref: int
18
+ configured: bool
19
+ blocked: bool
20
+ verified: bool
21
+ retries: int | None # remaining tries (None if unknown / not configured)
22
+
23
+ def to_dict(self) -> dict[str, object]:
24
+ return {
25
+ "ref": f"{self.ref:02X}",
26
+ "configured": self.configured,
27
+ "blocked": self.blocked,
28
+ "verified": self.verified,
29
+ "retries_remaining": self.retries,
30
+ }
31
+
32
+
33
+ def _pin_status_from_sw(ref: int, resp: Response) -> PinStatus:
34
+ sw = resp.sw
35
+ if sw == 0x9000: # already verified in this session
36
+ return PinStatus(ref, configured=True, blocked=False, verified=True, retries=None)
37
+ if resp.sw1 == 0x63 and (resp.sw2 & 0xF0) == 0xC0:
38
+ tries = resp.sw2 & 0x0F
39
+ return PinStatus(ref, configured=True, blocked=tries == 0, verified=False, retries=tries)
40
+ if sw == 0x6983: # blocked
41
+ return PinStatus(ref, configured=True, blocked=True, verified=False, retries=0)
42
+ if sw in (0x6A88, 0x6A80, 0x6A82): # reference not configured
43
+ return PinStatus(ref, configured=False, blocked=False, verified=False, retries=None)
44
+ # Unknown — report not-configured-ish but keep retries None.
45
+ return PinStatus(ref, configured=False, blocked=False, verified=False, retries=None)
46
+
47
+
48
+ def _pad_pin(pin: bytes) -> bytes:
49
+ """PIV PINs are 8 bytes, padded with 0xFF."""
50
+ if len(pin) > 8:
51
+ raise ValueError("PIN must be at most 8 bytes")
52
+ return bytes(pin) + b"\xff" * (8 - len(pin))
53
+
54
+
55
+ class PivApplet:
56
+ """Thin, read-only PIV client. Mutating commands arrive in later phases."""
57
+
58
+ def __init__(self, session: CardSession) -> None:
59
+ self.session = session
60
+ self._apt: APTInfo | None = None
61
+
62
+ # -- selection ---------------------------------------------------------- #
63
+ def select(self) -> APTInfo:
64
+ resp = self.session.transmit(
65
+ APDU(0x00, c.INS_SELECT, 0x04, 0x00, data=c.PIV_AID, le=256), context="SELECT PIV"
66
+ )
67
+ # On the multi-applet card, the first case-4 ISO SELECT issued right after a
68
+ # DESFire native-command session is rejected with 6700 ("wrong length"); the
69
+ # identical SELECT sent as case-3 (no Le) is accepted and still returns the
70
+ # FCI. The composite StateDetector probes DESFire first, so without this the
71
+ # PIV applet is mis-reported as Unknown over a contactless reader. Retry once
72
+ # without Le. See docs/troubleshooting.md.
73
+ if resp.sw == 0x6700:
74
+ resp = self.session.transmit(
75
+ APDU(0x00, c.INS_SELECT, 0x04, 0x00, data=c.PIV_AID), context="SELECT PIV (no Le)"
76
+ )
77
+ if resp.sw == 0x6A82:
78
+ raise AppletNotFoundError("PIV applet not found on this card.")
79
+ if not resp.ok:
80
+ raise StatusWordError(resp.sw1, resp.sw2, context="SELECT PIV")
81
+ self._apt = parse_apt(resp.data)
82
+ return self._apt
83
+
84
+ def try_select(self) -> APTInfo | None:
85
+ try:
86
+ return self.select()
87
+ except AppletNotFoundError:
88
+ return None
89
+
90
+ @property
91
+ def apt(self) -> APTInfo | None:
92
+ return self._apt
93
+
94
+ # -- data objects ------------------------------------------------------- #
95
+ def get_data(self, oid: bytes) -> Response:
96
+ return self.session.transmit(obj.get_data_apdu(oid), context="GET DATA")
97
+
98
+ def read_object(self, oid: bytes) -> bytes | None:
99
+ """Return the unwrapped object content, or None if absent (6A82)."""
100
+ resp = self.get_data(oid)
101
+ if resp.sw == 0x6A82:
102
+ return None
103
+ if not resp.ok:
104
+ # Access-gated objects (e.g. PIN-protected) may return 6982 — surface as absent-ish.
105
+ if resp.sw in (0x6982, 0x6983):
106
+ return None
107
+ raise StatusWordError(resp.sw1, resp.sw2, context="GET DATA")
108
+ return obj.unwrap(oid, resp.data)
109
+
110
+ def object_present(self, oid: bytes) -> bool:
111
+ """True if the object holds content (or exists but is access-gated).
112
+
113
+ Two distinct "absent" cases per SP 800-73 4.1.1 (same clause in -4 and
114
+ -5): a container never created at pre-personalization returns 6A82,
115
+ while a created-but-unused container returns a zero-length object
116
+ (9000). Both count as absent for every caller's purpose (status, slots,
117
+ validate, quickstart).
118
+ """
119
+ resp = self.get_data(oid)
120
+ if resp.sw in (0x6982, 0x6983): # exists but access-gated still counts
121
+ return True
122
+ if not resp.ok:
123
+ return False
124
+ return bool(obj.unwrap(oid, resp.data))
125
+
126
+ # -- PIN / PUK ---------------------------------------------------------- #
127
+ def pin_status(self, ref: int = c.REF_PIV_PIN) -> PinStatus:
128
+ """Non-decrementing status query (VERIFY with empty body)."""
129
+ resp = self.session.transmit(APDU(0x00, c.INS_VERIFY, 0x00, ref), context="VERIFY (status)")
130
+ return _pin_status_from_sw(ref, resp)
131
+
132
+ def verify_pin(self, pin: bytes, ref: int = c.REF_PIV_PIN) -> Response:
133
+ """Verify a PIN. NOTE: a wrong PIN decrements the retry counter."""
134
+ self.session.redactor.register(pin)
135
+ padded = _pad_pin(pin)
136
+ self.session.redactor.register(padded)
137
+ return self.session.transmit(
138
+ APDU(0x00, c.INS_VERIFY, 0x00, ref, data=padded), context="VERIFY PIN"
139
+ )
140
+
141
+ def change_reference(self, old: bytes, new: bytes, ref: int = c.REF_PIV_PIN) -> Response:
142
+ """Cardholder CHANGE REFERENCE DATA (INS 24, P1=00): swap a PIN or PUK value.
143
+
144
+ Body is the current value then the new value, each 0xFF-padded to 8 bytes.
145
+ A wrong ``old`` decrements the ref's retry counter (same as VERIFY). No admin
146
+ channel: the cardholder proves knowledge of the current value in-band.
147
+ """
148
+ for secret in (old, new):
149
+ self.session.redactor.register(secret)
150
+ body = _pad_pin(old) + _pad_pin(new)
151
+ self.session.redactor.register(body)
152
+ return self.session.transmit(
153
+ APDU(0x00, c.INS_CHANGE_REFERENCE_DATA, 0x00, ref, data=body),
154
+ context="CHANGE REFERENCE DATA",
155
+ )
156
+
157
+ def unblock_pin(self, puk: bytes, new_pin: bytes, ref: int = c.REF_PIV_PIN) -> Response:
158
+ """RESET RETRY COUNTER (INS 2C): unblock the PIN by proving the PUK.
159
+
160
+ Body is the PUK then the new PIN, each 0xFF-padded to 8 bytes. Resets the
161
+ PIN retry counter to its configured maximum. A wrong PUK decrements the PUK's
162
+ own counter; if the PUK blocks too, the PIN is unrecoverable.
163
+ """
164
+ for secret in (puk, new_pin):
165
+ self.session.redactor.register(secret)
166
+ body = _pad_pin(puk) + _pad_pin(new_pin)
167
+ self.session.redactor.register(body)
168
+ return self.session.transmit(
169
+ APDU(0x00, c.INS_RESET_RETRY, 0x00, ref, data=body),
170
+ context="RESET RETRY COUNTER",
171
+ )
@@ -0,0 +1,174 @@
1
+ """OpenFIPS201 PUT DATA ADMIN grammar (pre-personalization / factory).
2
+
3
+ Mirrors the applet's own perso toolkit (``tools/perso/piv_admin.py``) byte-for-byte.
4
+ Builders emit only the CDATA; the SCP03 layer wraps it into the secured APDU
5
+ ``84 DB 3F 00 <Lc> <payload> <C-MAC>``.
6
+
7
+ Create-object parse order enforced by the applet (sequential TLVReader):
8
+ common prefix: 8B object-id, 8C mode-contact, 8D mode-contactless, [91 admin-key]
9
+ CONTAINER (0x64): nothing further
10
+ VERIFIER (0x65): 8E min-len, 8F max-len, 90 retries-contact, 91 retries-contactless,
11
+ [92 charset] [93 history] [94 sequence] [95 repeat] [96 restrict-update]
12
+ KEY (0x66): 8E mechanism, 8F role, 90 attributes
13
+ The order is significant — do not reorder elements.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Sequence
19
+
20
+ from cryptnox_id_cli.transport.apdu import APDU
21
+ from cryptnox_id_cli.util.tlv import build, build_constructed, concat
22
+
23
+ # PUT DATA ADMIN command header.
24
+ INS_PUT_DATA = 0xDB
25
+ PUT_DATA_P1 = 0x3F
26
+ PUT_DATA_P2_ADMIN = 0x00
27
+
28
+ # Operation tags.
29
+ OP_CREATE_CONTAINER = 0x64
30
+ OP_CREATE_VERIFIER = 0x65
31
+ OP_CREATE_KEY = 0x66
32
+ OP_UPDATE_CONFIG = 0x68
33
+ OP_SECURE_APPLET = 0x5F
34
+ OP_BULK_REQUEST = 0x6A
35
+
36
+ # Common element tags.
37
+ TAG_OBJECT_ID = 0x8B
38
+ TAG_MODE_CONTACT = 0x8C
39
+ TAG_MODE_CONTACTLESS = 0x8D
40
+ TAG_ADMIN_KEY = 0x91 # optional, container/key only (NOT verifier)
41
+
42
+ # Key element tags.
43
+ TAG_KEY_MECHANISM = 0x8E
44
+ TAG_KEY_ROLE = 0x8F
45
+ TAG_KEY_ATTRIBUTE = 0x90
46
+
47
+ # Verifier element tags.
48
+ TAG_PIN_MIN_LENGTH = 0x8E
49
+ TAG_PIN_MAX_LENGTH = 0x8F
50
+ TAG_PIN_RETRIES_CONTACT = 0x90
51
+ TAG_PIN_RETRIES_CONTACTLESS = 0x91
52
+ TAG_PIN_RULE_CHARSET = 0x92
53
+ TAG_PIN_RULE_HISTORY = 0x93
54
+ TAG_PIN_RULE_SEQUENCE = 0x94
55
+ TAG_PIN_RULE_REPEAT = 0x95
56
+ TAG_PIN_RESTRICT_UPDATE = 0x96
57
+
58
+ # Access-mode bytes. A mode is a bitmap EXCEPT ALWAYS which is the special 0x3F.
59
+ MODE_NEVER = 0x00
60
+ MODE_PIN = 0x01
61
+ MODE_OCC = 0x04
62
+ MODE_SM = 0x40
63
+ MODE_USER_ADMIN = 0x80
64
+ MODE_ALWAYS = 0x3F
65
+ MODE_VCI = MODE_SM # 0x40 (contactless read gated to a PIV-SM session)
66
+ MODE_VCI_PIN = MODE_SM | MODE_PIN # 0x41
67
+
68
+ # Key roles and attributes (bitmaps).
69
+ ROLE_AUTHENTICATE = 0x01
70
+ ROLE_KEY_ESTABLISH = 0x02
71
+ ROLE_SIGN = 0x04
72
+
73
+ ATTR_PERMIT_EXTERNAL = 0x04
74
+ ATTR_PERMIT_MUTUAL = 0x08
75
+ ATTR_IMPORTABLE = 0x10
76
+ ATTR_RSA_CRT = 0x20
77
+
78
+
79
+ def _object_id_bytes(object_id: int | bytes | Sequence[int]) -> bytes:
80
+ if isinstance(object_id, int):
81
+ return bytes([object_id])
82
+ return bytes(object_id)
83
+
84
+
85
+ def create_container(
86
+ object_id: int | bytes,
87
+ mode_contact: int,
88
+ mode_contactless: int,
89
+ admin_key: int | None = None,
90
+ ) -> bytes:
91
+ parts = [
92
+ build(TAG_OBJECT_ID, _object_id_bytes(object_id)),
93
+ build(TAG_MODE_CONTACT, bytes([mode_contact])),
94
+ build(TAG_MODE_CONTACTLESS, bytes([mode_contactless])),
95
+ ]
96
+ if admin_key is not None:
97
+ parts.append(build(TAG_ADMIN_KEY, bytes([admin_key])))
98
+ return build_constructed(OP_CREATE_CONTAINER, *parts)
99
+
100
+
101
+ def create_verifier(
102
+ verifier_id: int,
103
+ mode_contact: int,
104
+ mode_contactless: int,
105
+ min_length: int,
106
+ max_length: int,
107
+ retries_contact: int,
108
+ retries_contactless: int,
109
+ charset: int | None = None,
110
+ history: int | None = None,
111
+ sequence: int | None = None,
112
+ repeat: int | None = None,
113
+ restrict_update: int | None = None,
114
+ ) -> bytes:
115
+ parts = [
116
+ build(TAG_OBJECT_ID, bytes([verifier_id])),
117
+ build(TAG_MODE_CONTACT, bytes([mode_contact])),
118
+ build(TAG_MODE_CONTACTLESS, bytes([mode_contactless])),
119
+ build(TAG_PIN_MIN_LENGTH, bytes([min_length])),
120
+ build(TAG_PIN_MAX_LENGTH, bytes([max_length])),
121
+ build(TAG_PIN_RETRIES_CONTACT, bytes([retries_contact])),
122
+ build(TAG_PIN_RETRIES_CONTACTLESS, bytes([retries_contactless])),
123
+ ]
124
+ for tag, val in (
125
+ (TAG_PIN_RULE_CHARSET, charset),
126
+ (TAG_PIN_RULE_HISTORY, history),
127
+ (TAG_PIN_RULE_SEQUENCE, sequence),
128
+ (TAG_PIN_RULE_REPEAT, repeat),
129
+ (TAG_PIN_RESTRICT_UPDATE, restrict_update),
130
+ ):
131
+ if val is not None:
132
+ parts.append(build(tag, bytes([val])))
133
+ return build_constructed(OP_CREATE_VERIFIER, *parts)
134
+
135
+
136
+ def create_key(
137
+ key_id: int,
138
+ mode_contact: int,
139
+ mode_contactless: int,
140
+ mechanism: int,
141
+ role: int,
142
+ attributes: int,
143
+ admin_key: int | None = None,
144
+ ) -> bytes:
145
+ parts = [
146
+ build(TAG_OBJECT_ID, bytes([key_id])),
147
+ build(TAG_MODE_CONTACT, bytes([mode_contact])),
148
+ build(TAG_MODE_CONTACTLESS, bytes([mode_contactless])),
149
+ ]
150
+ if admin_key is not None:
151
+ parts.append(build(TAG_ADMIN_KEY, bytes([admin_key])))
152
+ parts.extend(
153
+ [
154
+ build(TAG_KEY_MECHANISM, bytes([mechanism])),
155
+ build(TAG_KEY_ROLE, bytes([role])),
156
+ build(TAG_KEY_ATTRIBUTE, bytes([attributes])),
157
+ ]
158
+ )
159
+ return build_constructed(OP_CREATE_KEY, *parts)
160
+
161
+
162
+ def secure_applet() -> bytes:
163
+ """SECURE APPLET (0x5F) — primitive, empty body. IRREVERSIBLE (locks the applet)."""
164
+ return build(OP_SECURE_APPLET, b"")
165
+
166
+
167
+ def build_bulk(ops: Sequence[bytes]) -> bytes:
168
+ """Wrap operation TLVs into a BULK request (0x6A)."""
169
+ return build_constructed(OP_BULK_REQUEST, concat(*ops))
170
+
171
+
172
+ def put_data_admin_apdu(payload: bytes) -> APDU:
173
+ """The (unsecured) PUT DATA ADMIN command; the SCP03 layer secures/wraps it."""
174
+ return APDU(0x00, INS_PUT_DATA, PUT_DATA_P1, PUT_DATA_P2_ADMIN, data=payload)