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,213 @@
1
+ """SCP03 secure channel (GlobalPlatform Amendment D), used for PIV admin commands.
2
+
3
+ Implements the SP 800-108 counter-mode KDF (AES-CMAC PRF), session-key derivation,
4
+ card/host cryptograms, C-MAC chaining and C-ENC command wrapping. Response security
5
+ (R-MAC/R-ENC) is not requested by default (security level 0x03 = C-MAC + C-DECRYPTION).
6
+
7
+ Correctness is proven end-to-end by the card: if our derived keys are wrong, the card
8
+ cryptogram check fails locally and EXTERNAL AUTHENTICATE is refused by the card.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass
15
+
16
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
17
+ from cryptography.hazmat.primitives.cmac import CMAC
18
+
19
+ from cryptnox_id_cli.transport.apdu import APDU, Response
20
+ from cryptnox_id_cli.transport.errors import Scp03Error
21
+
22
+ _BLOCK = 16
23
+
24
+ # KDF derivation constants (GP Amd D, Table 4-1).
25
+ _DERIV_CARD_CRYPTOGRAM = 0x00
26
+ _DERIV_HOST_CRYPTOGRAM = 0x01
27
+ _DERIV_S_ENC = 0x04
28
+ _DERIV_S_MAC = 0x06
29
+ _DERIV_S_RMAC = 0x07
30
+
31
+ # Security-level bits (EXTERNAL AUTHENTICATE P1).
32
+ SL_C_MAC = 0x01
33
+ SL_C_DECRYPTION = 0x02
34
+ SL_R_MAC = 0x10
35
+ SL_R_ENCRYPTION = 0x20
36
+ SL_CMAC_CENC = SL_C_MAC | SL_C_DECRYPTION # 0x03
37
+
38
+
39
+ def _aes_cmac(key: bytes, data: bytes) -> bytes:
40
+ c = CMAC(algorithms.AES(key))
41
+ c.update(data)
42
+ return c.finalize()
43
+
44
+
45
+ def _aes_ecb(key: bytes, block: bytes) -> bytes:
46
+ enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor() # noqa: S305 - single-block ICV
47
+ return enc.update(block) + enc.finalize()
48
+
49
+
50
+ def _aes_cbc_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
51
+ enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
52
+ return enc.update(data) + enc.finalize()
53
+
54
+
55
+ def _kdf(key: bytes, constant: int, context: bytes, length_bits: int) -> bytes:
56
+ """SP 800-108 counter-mode KDF with an AES-CMAC PRF (SCP03 flavour)."""
57
+ out = b""
58
+ counter = 1
59
+ while len(out) * 8 < length_bits:
60
+ block = (
61
+ b"\x00" * 11
62
+ + bytes([constant])
63
+ + b"\x00"
64
+ + length_bits.to_bytes(2, "big")
65
+ + bytes([counter])
66
+ + context
67
+ )
68
+ out += _aes_cmac(key, block)
69
+ counter += 1
70
+ return out[: length_bits // 8]
71
+
72
+
73
+ def _pad80(data: bytes) -> bytes:
74
+ """ISO 7816-4 / SCP03 padding: 0x80 then 0x00s to the next block boundary."""
75
+ padded = data + b"\x80"
76
+ if len(padded) % _BLOCK:
77
+ padded += b"\x00" * (_BLOCK - len(padded) % _BLOCK)
78
+ return padded
79
+
80
+
81
+ @dataclass
82
+ class Scp03Keys:
83
+ enc: bytes
84
+ mac: bytes
85
+ dek: bytes
86
+
87
+ @classmethod
88
+ def same(cls, key: bytes) -> Scp03Keys:
89
+ return cls(key, key, key)
90
+
91
+ @property
92
+ def key_bits(self) -> int:
93
+ return len(self.enc) * 8
94
+
95
+
96
+ class Scp03Session:
97
+ """An open SCP03 session: holds session keys and wraps/unwraps command APDUs."""
98
+
99
+ def __init__(self, s_enc: bytes, s_mac: bytes, s_rmac: bytes, security_level: int) -> None:
100
+ self.s_enc = s_enc
101
+ self.s_mac = s_mac
102
+ self.s_rmac = s_rmac
103
+ self.security_level = security_level
104
+ self.mac_chaining = b"\x00" * _BLOCK
105
+ self.encrypt_counter = 1
106
+
107
+ def _encrypt(self, data: bytes) -> bytes:
108
+ if not data:
109
+ return data
110
+ icv = _aes_ecb(self.s_enc, self.encrypt_counter.to_bytes(_BLOCK, "big"))
111
+ return _aes_cbc_encrypt(self.s_enc, icv, _pad80(data))
112
+
113
+ def wrap(self, apdu: APDU) -> bytes:
114
+ """Return the wrapped command bytes (CLA|0x04, optional C-ENC, C-MAC appended).
115
+
116
+ Supports short and extended Lc (the pre-perso BULK is ~316 bytes -> extended).
117
+ The C-MAC is computed over the command exactly as transmitted (incl. the Lc
118
+ field), then its first 8 bytes are appended.
119
+ """
120
+ data = apdu.data
121
+ if self.security_level & SL_C_DECRYPTION:
122
+ data = self._encrypt(data)
123
+ lc = len(data) + 8 # +8 for the C-MAC
124
+ cla = apdu.cla | 0x04
125
+ if lc <= 0xFF:
126
+ header = bytes([cla, apdu.ins, apdu.p1, apdu.p2, lc])
127
+ le_bytes = (
128
+ b"" if apdu.le is None else bytes([0x00 if apdu.le >= 256 else apdu.le & 0xFF])
129
+ )
130
+ else: # extended length
131
+ header = bytes([cla, apdu.ins, apdu.p1, apdu.p2, 0x00, (lc >> 8) & 0xFF, lc & 0xFF])
132
+ le_bytes = b"" if apdu.le is None else (apdu.le & 0xFFFF).to_bytes(2, "big")
133
+ full_mac = _aes_cmac(self.s_mac, self.mac_chaining + header + data)
134
+ self.mac_chaining = full_mac
135
+ self.encrypt_counter += 1
136
+ return header + data + full_mac[:8] + le_bytes
137
+
138
+ def unwrap(self, resp: Response) -> Response:
139
+ """With level 0x03 the response is plaintext; pass it through."""
140
+ if self.security_level & (SL_R_MAC | SL_R_ENCRYPTION):
141
+ raise Scp03Error("R-MAC/R-ENC responses are not implemented")
142
+ return resp
143
+
144
+
145
+ def derive_session_keys(
146
+ keys: Scp03Keys, host_challenge: bytes, card_challenge: bytes
147
+ ) -> tuple[bytes, bytes, bytes]:
148
+ context = host_challenge + card_challenge
149
+ bits = keys.key_bits
150
+ s_enc = _kdf(keys.enc, _DERIV_S_ENC, context, bits)
151
+ s_mac = _kdf(keys.mac, _DERIV_S_MAC, context, bits)
152
+ s_rmac = _kdf(keys.mac, _DERIV_S_RMAC, context, bits)
153
+ return s_enc, s_mac, s_rmac
154
+
155
+
156
+ def _cryptogram(s_mac: bytes, constant: int, host_challenge: bytes, card_challenge: bytes) -> bytes:
157
+ return _kdf(s_mac, constant, host_challenge + card_challenge, 64)
158
+
159
+
160
+ def open_channel(
161
+ transmit,
162
+ keys: Scp03Keys,
163
+ *,
164
+ key_version: int = 0,
165
+ security_level: int = SL_CMAC_CENC,
166
+ host_challenge: bytes | None = None,
167
+ init_response: Response | None = None,
168
+ ) -> Scp03Session:
169
+ """Run INITIALIZE UPDATE + EXTERNAL AUTHENTICATE. ``transmit(APDU|bytes)->Response``.
170
+
171
+ If ``init_response`` is given (the caller already issued INITIALIZE UPDATE to
172
+ auto-detect the SCP version), it is reused with the matching ``host_challenge``
173
+ instead of issuing a second one.
174
+
175
+ Raises Scp03Error on a wrong key (card cryptogram mismatch) or a card rejection.
176
+ """
177
+ if init_response is None:
178
+ host_challenge = host_challenge or os.urandom(8)
179
+ resp = transmit(APDU(0x80, 0x50, key_version, 0x00, data=host_challenge, le=256))
180
+ else:
181
+ if host_challenge is None:
182
+ raise Scp03Error("host_challenge is required when init_response is supplied.")
183
+ resp = init_response
184
+ if not resp.ok:
185
+ raise Scp03Error(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).")
186
+ body = resp.data
187
+ if len(body) < 29:
188
+ raise Scp03Error(f"INITIALIZE UPDATE response too short ({len(body)} bytes).")
189
+ card_challenge = body[13:21]
190
+ card_cryptogram = body[21:29]
191
+
192
+ s_enc, s_mac, s_rmac = derive_session_keys(keys, host_challenge, card_challenge)
193
+
194
+ expected = _cryptogram(s_mac, _DERIV_CARD_CRYPTOGRAM, host_challenge, card_challenge)
195
+ if expected != card_cryptogram:
196
+ raise Scp03Error("Card cryptogram mismatch - the SCP03 keys are wrong for this card.")
197
+
198
+ session = Scp03Session(s_enc, s_mac, s_rmac, security_level)
199
+ host_cryptogram = _cryptogram(s_mac, _DERIV_HOST_CRYPTOGRAM, host_challenge, card_challenge)
200
+
201
+ # EXTERNAL AUTHENTICATE: CLA 0x84, Lc=0x10 (8-byte host cryptogram + 8-byte C-MAC).
202
+ header = bytes([0x84, 0x82, security_level, 0x00, 0x10])
203
+ full_mac = _aes_cmac(session.s_mac, session.mac_chaining + header + host_cryptogram)
204
+ session.mac_chaining = full_mac
205
+ ext_auth = header + host_cryptogram + full_mac[:8]
206
+
207
+ resp = transmit(ext_auth)
208
+ if not resp.ok:
209
+ raise Scp03Error(
210
+ f"EXTERNAL AUTHENTICATE rejected (SW={resp.sw_hex()}); "
211
+ "keys or security level may be wrong."
212
+ )
213
+ return session
@@ -0,0 +1,90 @@
1
+ """Pinned trust anchors for offline attestation-chain verification.
2
+
3
+ PEM certificates dropped into ``trust/genuine/`` are bundled into the package (and the
4
+ PyInstaller binary) and loaded here. Anchors are classified by inspection, not by filename:
5
+
6
+ * self-issued (issuer == subject) **CA** certificates -> **roots** (pinned anchors)
7
+ * non-self-issued **CA** certificates -> **intermediates**
8
+
9
+ A relying party trusts a chain only because it terminates at one of these pinned roots — never
10
+ because a root happens to appear in a chain read off a card. If the directory is empty, the
11
+ loaders return empty lists and callers must report that the chain cannot be verified rather
12
+ than silently passing.
13
+
14
+ The bundled set is the Cryptnox production PKI: ``CN=CRYPTNOX ROOT CA`` (the anchor) plus its
15
+ intermediates. Only the root grants trust; the intermediates are shipped so chain building
16
+ works offline when a card omits part of its chain.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from importlib.resources import files
23
+ from pathlib import Path
24
+
25
+ from cryptography import x509
26
+ from cryptography.hazmat.primitives import serialization
27
+
28
+ _GENUINE_PKG = "cryptnox_id_cli.trust"
29
+ _GENUINE_DIR = "genuine"
30
+
31
+ #: Point this at a directory of PEM anchors to use a trust store without rebuilding
32
+ #: the package - e.g. anchors fetched from the Cryptnox PKI, or a lab/dev CA. Its
33
+ #: certificates are added to whatever ships bundled; it never disables the bundled set.
34
+ TRUST_DIR_ENV = "CRYPTNOX_TRUST_DIR"
35
+
36
+
37
+ def _parse_pem_dir(root) -> list[x509.Certificate]:
38
+ """Parse every ``*.pem`` in a directory (each file may hold >1 certificate)."""
39
+ out: list[x509.Certificate] = []
40
+ if not root.is_dir():
41
+ return out
42
+ for entry in sorted(root.iterdir(), key=lambda e: e.name):
43
+ if entry.name.lower().endswith(".pem") and entry.is_file():
44
+ out.extend(x509.load_pem_x509_certificates(entry.read_bytes()))
45
+ return out
46
+
47
+
48
+ def _load_all(extra_dir: str | os.PathLike[str] | None = None) -> list[x509.Certificate]:
49
+ """Bundled anchors, plus any from ``$CRYPTNOX_TRUST_DIR`` and ``extra_dir``."""
50
+ out = _parse_pem_dir(files(_GENUINE_PKG).joinpath(_GENUINE_DIR))
51
+ for candidate in (os.environ.get(TRUST_DIR_ENV), extra_dir):
52
+ if candidate:
53
+ out.extend(_parse_pem_dir(Path(candidate)))
54
+ return out
55
+
56
+
57
+ def _is_ca(cert: x509.Certificate) -> bool:
58
+ try:
59
+ return bool(cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
60
+ except x509.ExtensionNotFound:
61
+ return False
62
+
63
+
64
+ def _der(cert: x509.Certificate) -> bytes:
65
+ return cert.public_bytes(serialization.Encoding.DER)
66
+
67
+
68
+ def load_anchors(
69
+ extra_dir: str | os.PathLike[str] | None = None,
70
+ ) -> tuple[list[bytes], list[bytes]]:
71
+ """Return ``(roots_der, intermediates_der)`` from the configured trust store.
72
+
73
+ Sources, in order and additive: the bundled ``trust/genuine/``, the directory named
74
+ by ``$CRYPTNOX_TRUST_DIR``, then ``extra_dir``. Duplicates are collapsed, so the same
75
+ anchor supplied twice is harmless. Roots are self-issued CAs (the pinned anchors);
76
+ intermediates are the remaining CAs. An empty result means callers must report that
77
+ the chain cannot be verified - never that it passed.
78
+ """
79
+ roots: list[bytes] = []
80
+ intermediates: list[bytes] = []
81
+ seen: set[bytes] = set()
82
+ for cert in _load_all(extra_dir):
83
+ if not _is_ca(cert):
84
+ continue # ignore non-CA material in the trust dir
85
+ der = _der(cert)
86
+ if der in seen:
87
+ continue
88
+ seen.add(der)
89
+ (roots if cert.issuer == cert.subject else intermediates).append(der)
90
+ return roots, intermediates
@@ -0,0 +1,18 @@
1
+ # subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ATTESTATION CA
2
+ # issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
3
+ # not after : Dec 30 14:55:00 2049 GMT
4
+ # sha256 (DER): DCC01FEBE28E6A5B21AA443B3A5EE0DAEB284F0F5D612AD815586F56EAEC46E5
5
+ -----BEGIN CERTIFICATE-----
6
+ MIICLDCCAbKgAwIBAgIIUEneQtSD39UwCgYIKoZIzj0EAwMwWjELMAkGA1UEBhMC
7
+ Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExJDAiBgNV
8
+ BAMMG0NSWVBUTk9YIElOVEVSTUVESUFURSBDQSAjMjAeFw0yNjA2MjkxNDU1MDBa
9
+ Fw00OTEyMzAxNDU1MDBaMFYxCzAJBgNVBAYTAkNIMQ8wDQYDVQQIDAZHRU5FVkEx
10
+ FDASBgNVBAoMC0NSWVBUTk9YIFNBMSAwHgYDVQQDDBdDUllQVE5PWCBBVFRFU1RB
11
+ VElPTiBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABM2dnWQbl7PHnP3Y5bMP
12
+ AD3kuhOj28tryE9uZSYcXFYJeqoAegRtMdoN0vzBopeDMevvfHPE5EODpJ5N1q7A
13
+ wrWjZjBkMB8GA1UdIwQYMBaAFLOIBaait0D7A0fxVv5E2aPBeqmZMBIGA1UdEwEB
14
+ /wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRK99VS1M/S3cuq
15
+ KYeBk5E0w7TfXzAKBggqhkjOPQQDAwNoADBlAjBLhsipCcuwxUZpGwfNW/UJNdhq
16
+ td4PShNDeOLKS1YG2miJ3Q+HEv9oH4cM9cYV1hwCMQDUEGDsatjZXcnNCN4CFrRO
17
+ sXrBy3Z3Z4Iipw9uFWfCJOLvGQA8cW+tXRFJ+DPxICA=
18
+ -----END CERTIFICATE-----
@@ -0,0 +1,17 @@
1
+ # subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX DLT CARDS CA
2
+ # issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
3
+ # not after : Dec 30 23:59:00 2049 GMT
4
+ # sha256 (DER): 474A05AB2FA4543CD4C03C2EBDC8140A29496E97F6D0F53FF72FEDC9D10BE3E0
5
+ -----BEGIN CERTIFICATE-----
6
+ MIICBDCCAYmgAwIBAgIIE3MdP3JzJZMwCgYIKoZIzj0EAwQwWjELMAkGA1UEBhMC
7
+ Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExJDAiBgNV
8
+ BAMMG0NSWVBUTk9YIElOVEVSTUVESUFURSBDQSAjMjAeFw0yMzA0MDkxNjQ4MDBa
9
+ Fw00OTEyMzAyMzU5MDBaMFQxCzAJBgNVBAYTAkNIMQ8wDQYDVQQIEwZHRU5FVkEx
10
+ FDASBgNVBAoTC0NSWVBUTk9YIFNBMR4wHAYDVQQDExVDUllQVE5PWCBETFQgQ0FS
11
+ RFMgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARkehEsKs9aSbUrMhy9z1qe
12
+ owf8TM0zqni5uAVd2E0DrtqwLMcgp4DLH/KAr1B3SmzcFX76I16iUxGkK0z1fYhh
13
+ oz8wPTAPBgNVHRMECDAGAQH/AgEAMB0GA1UdDgQWBBRgo23D82CwTUbrY+ZzvVla
14
+ jaO+RjALBgNVHQ8EBAMCAQYwCgYIKoZIzj0EAwQDaQAwZgIxANVDF6ucXBS9+KlA
15
+ 3Po/tlMbVz5qttJgTGJhiGIbUqdIcSj5O9EdqBc0cl+G/bYZywIxAJSjBG01D0Qa
16
+ L1qs8BGfmndV6g0vISjQYhXuwDQxVewN5It12yHlzlx0o+AIETxu+A==
17
+ -----END CERTIFICATE-----
@@ -0,0 +1,18 @@
1
+ # subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX GENUINENESS CA
2
+ # issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
3
+ # not after : Dec 30 15:07:00 2049 GMT
4
+ # sha256 (DER): 6B4F7DDE0366DF547FEB4AC66F79CD7C5AD56AB4A8EAD678EEBA293F48DBFDBB
5
+ -----BEGIN CERTIFICATE-----
6
+ MIICKzCCAbKgAwIBAgIIGQjzKGS8xbMwCgYIKoZIzj0EAwMwWjELMAkGA1UEBhMC
7
+ Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExJDAiBgNV
8
+ BAMMG0NSWVBUTk9YIElOVEVSTUVESUFURSBDQSAjMjAeFw0yNjA2MjkxNTA3MDBa
9
+ Fw00OTEyMzAxNTA3MDBaMFYxCzAJBgNVBAYTAkNIMQ8wDQYDVQQIDAZHRU5FVkEx
10
+ FDASBgNVBAoMC0NSWVBUTk9YIFNBMSAwHgYDVQQDDBdDUllQVE5PWCBHRU5VSU5F
11
+ TkVTUyBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEs4rWlxYxtChOWR3mTO
12
+ 9P1jlthn2Cv8p4mCOMI/uvm+bjbUVrtozu9wbtcY2kJUaRqKVzOI3H8enCea5L+v
13
+ X/ujZjBkMB8GA1UdIwQYMBaAFLOIBaait0D7A0fxVv5E2aPBeqmZMBIGA1UdEwEB
14
+ /wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTx6oiDpR/k6AzR
15
+ BjV2/PYc4viJrzAKBggqhkjOPQQDAwNnADBkAjBYqQrqfT/RvWzd4VmstWLf2Sba
16
+ WAyoTW1VOD6YlJkAt/WlWa5xXFH5oSTjS7io3sMCMHsF+jDdVUe+3DmzOhJZa70b
17
+ +b0n0wAG9cWuGqUp9u1bqpYbvVIqXSKqFzG5vcpTMw==
18
+ -----END CERTIFICATE-----
@@ -0,0 +1,19 @@
1
+ # subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
2
+ # issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
3
+ # not after : Dec 30 23:59:00 2049 GMT
4
+ # sha256 (DER): 94DB7D0A3EE6FAAB717C249E28370EF50E6CDF57DC930C878641D57E989B69BC
5
+ -----BEGIN CERTIFICATE-----
6
+ MIICPzCCAaGgAwIBAgIIXrvLbag/zUAwCgYIKoZIzj0EAwMwTzELMAkGA1UEBhMC
7
+ Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExGTAXBgNV
8
+ BAMTEENSWVBUTk9YIFJPT1QgQ0EwHhcNMjMwNDA5MTY0MjAwWhcNNDkxMjMwMjM1
9
+ OTAwWjBaMQswCQYDVQQGEwJDSDEPMA0GA1UECBMGR0VORVZBMRQwEgYDVQQKEwtD
10
+ UllQVE5PWCBTQTEkMCIGA1UEAwwbQ1JZUFROT1ggSU5URVJNRURJQVRFIENBICMy
11
+ MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEEflDieulDZW610ppouL3ISytLWoUFbKl
12
+ 03SZDujzHps/mRbR2VGCn385vOHzEY+4LpB1aS8uhYwrOt5ELJtMpM1M0JIKwr2S
13
+ dtOSv3iAzy0yAZq25id61an56sVZ/FP8oz8wPTALBgNVHQ8EBAMCAQYwDwYDVR0T
14
+ BAgwBgEB/wIBATAdBgNVHQ4EFgQUs4gFpqK3QPsDR/FW/kTZo8F6qZkwCgYIKoZI
15
+ zj0EAwMDgYsAMIGHAkIAwY7rXvM4h4XXMuXnZe/wjLc8kbPb8fx26v3ARY1ymb61
16
+ G62WutOrEUG0SJr2qzwyyWBh6SSaz2W/PnTXGGCysM0CQU4IZwYuY2uW5c0fGKnK
17
+ ecWkXTtARXiIjFn33zIbIcpwPHZ02zI2SH1pWSj6uRZBXqBQmtQY46nsNzjgUWge
18
+ +8vv
19
+ -----END CERTIFICATE-----
@@ -0,0 +1,19 @@
1
+ # subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA
2
+ # issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
3
+ # not after : Dec 29 23:59:59 2049 GMT
4
+ # sha256 (DER): 8CCF5B7AE498A2E3B7C21E9EB80843198380202C5AB57E60D2C1240587A050CA
5
+ -----BEGIN CERTIFICATE-----
6
+ MIICQTCCAaKgAwIBAgIGAIFDUXQrMAoGCCqGSM49BAMDME8xCzAJBgNVBAYTAkNI
7
+ MQ8wDQYDVQQIEwZHRU5FVkExFDASBgNVBAoTC0NSWVBUTk9YIFNBMRkwFwYDVQQD
8
+ ExBDUllQVE5PWCBST09UIENBMB4XDTIwMDYxNDAwMDAwMFoXDTQ5MTIyOTIzNTk1
9
+ OVowVzELMAkGA1UEBhMCQ0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZ
10
+ UFROT1ggU0ExITAfBgNVBAMTGENSWVBUTk9YIElOVEVSTUVESUFURSBDQTB2MBAG
11
+ ByqGSM49AgEGBSuBBAAiA2IABGhcikuRYgNkak0pO8XgPWwG9RZXLQeqQEvKHfCn
12
+ 37gaR5U9rhp4W8JTbfU9t8CFgD0DxNXEJE9KGNnLAtL708hCHeo6S5oeznGDFn9J
13
+ 80bO7bdSDYHS5bzkEKPLNuamWKNFMEMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNV
14
+ HQ4EFgQUdrEtUyWaMtpVAQ7JGqQp3jzNShcwDgYDVR0PAQH/BAQDAgEGMAoGCCqG
15
+ SM49BAMDA4GMADCBiAJCAbWa/xcKx7KCcoUsp8SVZnWSxD8n91TqCJKajs7kJBz2
16
+ DzvZZtPxKsMY38lH/8MYTC3wlRwdZc5s61EhlbdnOguBAkIBFmJLsOPyMQWUxmWl
17
+ 2zuAS7ctqU03U74C3UUHSKDH3WSJH+y3i/XsCs/FM+Dk/3FjOgQTxioaqIc3hrwh
18
+ PlmorNo=
19
+ -----END CERTIFICATE-----
@@ -0,0 +1,19 @@
1
+ # subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
2
+ # issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
3
+ # not after : Dec 30 23:59:59 2049 GMT
4
+ # sha256 (DER): 9EA907A7CBDA4E3D7F8EA06E6B6B759320DFE6F09272101F5E8EA85B3433EE2E
5
+ -----BEGIN CERTIFICATE-----
6
+ MIICWTCCAbqgAwIBAgIGAIFDUXQpMAoGCCqGSM49BAMEME8xCzAJBgNVBAYTAkNI
7
+ MQ8wDQYDVQQIEwZHRU5FVkExFDASBgNVBAoTC0NSWVBUTk9YIFNBMRkwFwYDVQQD
8
+ ExBDUllQVE5PWCBST09UIENBMB4XDTIwMDYxNDAwMDAwMFoXDTQ5MTIzMDIzNTk1
9
+ OVowTzELMAkGA1UEBhMCQ0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZ
10
+ UFROT1ggU0ExGTAXBgNVBAMTEENSWVBUTk9YIFJPT1QgQ0EwgZswEAYHKoZIzj0C
11
+ AQYFK4EEACMDgYYABAEJtmrMYc48nPSp0RRmEwT2U5aq0D1b3URLpmJP277ImEXK
12
+ EbjVQA8P3UyTdZinELTQ5g6E+lnN3GhUWif2/VmSbQDnu9f+ebyIZfBabosKK3SQ
13
+ Vv4KmuP9pb0f7P2vMPAUFJNZvKeH1DPtaZkFybMKZsg8CJEs7A9KW3RI/8QDpN1n
14
+ J6M/MD0wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQUUnfLODDaku1o8CSuwWfWylj4
15
+ OvAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMEA4GMADCBiAJCAKd8DeNhbPey
16
+ fh7UD3M9lPp5a77M1LmC2M2o7zRiHlePt+Xcr/I/xGK124gEuHjbOfyanre63EMT
17
+ uVW8ts5kR906AkIBgSIhJoENkMVFbeMRUDnDtbv+gKi+htqORGysid5sg2VvCX9Q
18
+ fnUqBKtZVUBeQVPk3E5GUEamLk4jljdvlDGSXpU=
19
+ -----END CERTIFICATE-----
@@ -0,0 +1 @@
1
+ """Low-level utilities shared across the CLI (hex, BER-TLV)."""
@@ -0,0 +1,36 @@
1
+ """Hex helpers. Centralised so formatting stays consistent everywhere."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+
8
+ def to_hex(data: Iterable[int] | bytes | bytearray, *, sep: str = "") -> str:
9
+ """Render bytes as uppercase hex, optionally separated (e.g. ``sep=" "``)."""
10
+ b = bytes(data)
11
+ if sep:
12
+ return sep.join(f"{x:02X}" for x in b)
13
+ return b.hex().upper()
14
+
15
+
16
+ def from_hex(text: str) -> bytes:
17
+ """Parse a hex string, tolerating spaces, colons and ``0x`` prefixes."""
18
+ cleaned = (
19
+ text.strip()
20
+ .replace("0x", "")
21
+ .replace("0X", "")
22
+ .replace(" ", "")
23
+ .replace(":", "")
24
+ .replace("-", "")
25
+ )
26
+ if len(cleaned) % 2 != 0:
27
+ raise ValueError(f"hex string has an odd number of digits: {text!r}")
28
+ try:
29
+ return bytes.fromhex(cleaned)
30
+ except ValueError as exc:
31
+ raise ValueError(f"invalid hex string: {text!r}") from exc
32
+
33
+
34
+ def to_list(data: bytes | bytearray | Iterable[int]) -> list[int]:
35
+ """pyscard wants ``list[int]`` for APDUs."""
36
+ return list(bytes(data))
@@ -0,0 +1,144 @@
1
+ """A small, dependency-free BER-TLV parser.
2
+
3
+ PIV data objects and the Application Property Template use BER-TLV with
4
+ multi-byte tags (e.g. ``5FC102``, ``7F61``) and short/long length forms. We need
5
+ read-only parsing only; this stays intentionally minimal and well-tested.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass
14
+ class TLV:
15
+ tag: int
16
+ value: bytes
17
+ children: list[TLV] = field(default_factory=list)
18
+
19
+ @property
20
+ def constructed(self) -> bool:
21
+ """True if the tag's constructed bit (0x20 in the leading byte) is set."""
22
+ lead = self.tag
23
+ while lead > 0xFF:
24
+ lead >>= 8
25
+ return bool(lead & 0x20)
26
+
27
+ def tag_hex(self) -> str:
28
+ length = max(1, (self.tag.bit_length() + 7) // 8)
29
+ return self.tag.to_bytes(length, "big").hex().upper()
30
+
31
+
32
+ def _read_tag(data: bytes, off: int) -> tuple[int, int]:
33
+ if off >= len(data):
34
+ raise ValueError("truncated tag")
35
+ start = off
36
+ first = data[off]
37
+ off += 1
38
+ tag = first
39
+ if (first & 0x1F) == 0x1F: # multi-byte tag
40
+ while True:
41
+ if off >= len(data):
42
+ raise ValueError("truncated multi-byte tag")
43
+ b = data[off]
44
+ tag = (tag << 8) | b
45
+ off += 1
46
+ if not (b & 0x80):
47
+ break
48
+ _ = start
49
+ return tag, off
50
+
51
+
52
+ def _read_len(data: bytes, off: int) -> tuple[int, int]:
53
+ if off >= len(data):
54
+ raise ValueError("truncated length")
55
+ first = data[off]
56
+ off += 1
57
+ if first < 0x80:
58
+ return first, off
59
+ num = first & 0x7F
60
+ if num == 0 or num > 4:
61
+ raise ValueError(f"unsupported BER length form: 0x{first:02X}")
62
+ if off + num > len(data):
63
+ raise ValueError("truncated length field")
64
+ length = int.from_bytes(data[off : off + num], "big")
65
+ return length, off + num
66
+
67
+
68
+ def parse(data: bytes | bytearray, *, recurse: bool = True) -> list[TLV]:
69
+ """Parse a concatenation of TLVs at the top level."""
70
+ data = bytes(data)
71
+ out: list[TLV] = []
72
+ off = 0
73
+ n = len(data)
74
+ while off < n:
75
+ tag, off = _read_tag(data, off)
76
+ length, off = _read_len(data, off)
77
+ if off + length > n:
78
+ raise ValueError("TLV value extends beyond buffer")
79
+ value = data[off : off + length]
80
+ off += length
81
+ node = TLV(tag=tag, value=value)
82
+ if recurse and node.constructed:
83
+ try:
84
+ node.children = parse(value, recurse=True)
85
+ except ValueError:
86
+ node.children = [] # not actually constructed payload; keep raw value
87
+ out.append(node)
88
+ return out
89
+
90
+
91
+ def find(tlvs: list[TLV], tag: int) -> TLV | None:
92
+ """Depth-first search for the first TLV with ``tag``."""
93
+ for t in tlvs:
94
+ if t.tag == tag:
95
+ return t
96
+ if t.children:
97
+ hit = find(t.children, tag)
98
+ if hit is not None:
99
+ return hit
100
+ return None
101
+
102
+
103
+ # --------------------------------------------------------------------------- #
104
+ # Encoders (BER-TLV) — mirror the applet's perso toolkit byte-for-byte. #
105
+ # --------------------------------------------------------------------------- #
106
+ def encode_tag(tag: int) -> bytes:
107
+ """Encode a numeric tag big-endian by its natural byte width (1..3 bytes)."""
108
+ if tag < 0:
109
+ raise ValueError("tag must be non-negative")
110
+ if tag <= 0xFF:
111
+ return bytes([tag])
112
+ if tag <= 0xFFFF:
113
+ return bytes([(tag >> 8) & 0xFF, tag & 0xFF])
114
+ if tag <= 0xFFFFFF:
115
+ return bytes([(tag >> 16) & 0xFF, (tag >> 8) & 0xFF, tag & 0xFF])
116
+ raise ValueError(f"tag too large: {tag:#x}")
117
+
118
+
119
+ def encode_length(n: int) -> bytes:
120
+ """BER length: short form < 0x80, else long form (0x81/0x82/0x83 + bytes)."""
121
+ if n < 0x80:
122
+ return bytes([n])
123
+ if n < 0x100:
124
+ return bytes([0x81, n])
125
+ if n < 0x10000:
126
+ return bytes([0x82, (n >> 8) & 0xFF, n & 0xFF])
127
+ if n < 0x1000000:
128
+ return bytes([0x83, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF])
129
+ raise ValueError(f"value too long: {n}")
130
+
131
+
132
+ def build(tag: int, value: bytes = b"") -> bytes:
133
+ """Build a single primitive/constructed TLV."""
134
+ value = bytes(value)
135
+ return encode_tag(tag) + encode_length(len(value)) + value
136
+
137
+
138
+ def concat(*parts: bytes) -> bytes:
139
+ return b"".join(bytes(p) for p in parts)
140
+
141
+
142
+ def build_constructed(tag: int, *children: bytes) -> bytes:
143
+ """Build a constructed TLV whose value is the concatenation of children."""
144
+ return build(tag, concat(*children))