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,66 @@
1
+ """PIV constants for the OpenFIPS201 applet under test.
2
+
3
+ Values reflect what THIS applet implements (verified from source + the live card),
4
+ not generic PIV assumptions: e.g. no 3DES, no RSA-1024/4096; admin key is AES-only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # AID observed on the card; the trailing 00 01 00 is the version/coexistent suffix.
10
+ PIV_AID = bytes.fromhex("A000000308000010000100")
11
+ PIV_RID = bytes.fromhex("A000000308")
12
+
13
+ # Instruction bytes (ISO 7816-4 / PIV).
14
+ INS_SELECT = 0xA4
15
+ INS_GET_DATA = 0xCB
16
+ INS_VERIFY = 0x20
17
+ INS_CHANGE_REFERENCE_DATA = 0x24
18
+ INS_RESET_RETRY = 0x2C
19
+ INS_GENERAL_AUTHENTICATE = 0x87
20
+ INS_PUT_DATA = 0xDB
21
+ INS_GENERATE_ASYMMETRIC = 0x47
22
+ INS_GET_RESPONSE = 0xC0
23
+
24
+ # Key-reference (slot) bytes.
25
+ KEYREF_PIV_AUTH = 0x9A
26
+ KEYREF_ADMIN = 0x9B # management/admin key (AES only on this applet)
27
+ KEYREF_DIGITAL_SIGNATURE = 0x9C
28
+ KEYREF_KEY_MANAGEMENT = 0x9D
29
+ KEYREF_CARD_AUTH = 0x9E
30
+ KEYREF_SECURE_MESSAGING = 0x04
31
+ KEYREF_RETIRED_FIRST = 0x82
32
+ KEYREF_RETIRED_LAST = 0x95
33
+
34
+ # Verifier references. The PIV Application PIN (0x80) is the only cardholder PIN this
35
+ # CLI provisions; the Global PIN (key ref 0x00) is intentionally not supported -- see
36
+ # docs/adr/0001-piv-application-pin-not-global-pin.md.
37
+ REF_PIV_PIN = 0x80
38
+ REF_PUK = 0x81
39
+
40
+ # Algorithm identifiers (SP 800-78). Only the ones THIS applet supports are "supported".
41
+ ALGORITHMS: dict[int, str] = {
42
+ 0x06: "RSA-1024",
43
+ 0x07: "RSA-2048",
44
+ 0x05: "RSA-3072",
45
+ 0x16: "RSA-4096",
46
+ 0x08: "AES-128",
47
+ 0x0A: "AES-192",
48
+ 0x0C: "AES-256",
49
+ 0x11: "ECC-P256",
50
+ 0x14: "ECC-P384",
51
+ 0x27: "PIV-SM-CS2 (AES-128/SHA-256)",
52
+ 0x2E: "PIV-SM-CS7 (AES-256/SHA-384)",
53
+ }
54
+
55
+ # Algorithms this OpenFIPS201 FIPS build actually implements. RSA-4096 (0x16) is
56
+ # included: on-card createKey + GENERATE for RSA-4096 is confirmed on the D600/SCP03
57
+ # hardware (returns a real 512-byte modulus), gated only by the chip's DF2B max-RSA
58
+ # = 0x1000 on a non-fused card. NOTE: this covers on-card key GENERATION and key
59
+ # objects; the host-side key-IMPORT/sign helpers (keyimport.py) still cover only
60
+ # RSA-2048/3072, so external RSA-4096 import is a separate, not-yet-implemented path.
61
+ SUPPORTED_ALGORITHMS: frozenset[int] = frozenset(
62
+ {0x07, 0x05, 0x16, 0x08, 0x0A, 0x0C, 0x11, 0x14, 0x27, 0x2E}
63
+ )
64
+
65
+ # Explicitly removed in this FIPS build (used to warn / never offer).
66
+ REMOVED_ALGORITHMS: frozenset[int] = frozenset({0x06}) # RSA-1024 (+ 3DES) — FIPS-gated
@@ -0,0 +1,244 @@
1
+ """PIV asymmetric private-key injection (CHANGE REFERENCE DATA ADMIN over SCP03).
2
+
3
+ OpenFIPS201 accepts externally generated key material with ``00 24 <P1=mechanism>
4
+ <P2=key reference>`` carrying exactly ONE key-element TLV per command, so an import
5
+ is a short sequence of admin commands (one fresh SCP03 session each on JCOP 4.5).
6
+ The target key object must already exist with the same (slot, mechanism) pair and
7
+ the IMPORTABLE attribute; RSA objects additionally fix CRT vs plain form at
8
+ creation. A key only becomes usable once both public and private parts are loaded,
9
+ and re-loading an initialised key requires a CLEAR first — every plan therefore
10
+ starts with CLEAR, which makes re-imports idempotent.
11
+
12
+ Element tags and exact-length rules (mirrors the applet's PIVKeyECC/PIVKeyRSA):
13
+ CLEAR ``9F`` (empty); ECC public point ``86`` (X9.62 uncompressed, 65/97 bytes) and
14
+ private scalar ``87`` (big-endian right-aligned, 32/48 bytes); RSA modulus ``81``
15
+ (k bytes), public exponent ``82`` (exactly 3 bytes), private exponent ``83``
16
+ (k bytes, plain form only), CRT components ``90``/``91``/``92``/``93``/``94``
17
+ (P/Q/dP/dQ/qInv, each k/2 bytes).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from cryptography.hazmat.primitives import hashes, serialization
25
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
26
+
27
+ from cryptnox_id_cli.applets.piv import constants as c
28
+ from cryptnox_id_cli.transport.apdu import APDU
29
+ from cryptnox_id_cli.transport.errors import CryptnoxError
30
+ from cryptnox_id_cli.util import tlv
31
+
32
+ INS_CHANGE_REFERENCE_DATA = 0x24
33
+ INS_GENERAL_AUTHENTICATE = 0x87
34
+
35
+ ELEMENT_CLEAR = 0x9F
36
+ ELEMENT_ECC_POINT = 0x86
37
+ ELEMENT_ECC_SECRET = 0x87
38
+ ELEMENT_RSA_N = 0x81
39
+ ELEMENT_RSA_E = 0x82
40
+ ELEMENT_RSA_D = 0x83
41
+ ELEMENT_RSA_P = 0x90
42
+ ELEMENT_RSA_Q = 0x91
43
+ ELEMENT_RSA_DP = 0x92
44
+ ELEMENT_RSA_DQ = 0x93
45
+ ELEMENT_RSA_PQ = 0x94
46
+
47
+ # Largest TLV body sent as a single (SCP03-wrapped) short APDU; larger bodies go
48
+ # through ISO command chaining. Matches PivAdmin.send_chained's plaintext budget.
49
+ SINGLE_APDU_MAX = 200
50
+
51
+ # Mechanism -> (curve, scalar/point sizes) for ECC, modulus size for RSA.
52
+ _EC_BY_MECH: dict[int, type[ec.EllipticCurve]] = {0x11: ec.SECP256R1, 0x14: ec.SECP384R1}
53
+ _EC_SCALAR_LEN = {0x11: 32, 0x14: 48}
54
+ _RSA_MODULUS_LEN = {0x07: 256, 0x05: 384}
55
+
56
+ # Host-side slot policy (SP 800-78 per-key-reference algorithm table): PKI slots
57
+ # and the retired key history accept the four asymmetric mechanisms; 9B is
58
+ # AES-only and 04 is secure-messaging-only. The deployed pristine v2.0.0-fips
59
+ # applet does NOT enforce this on-card (the 800-73-5 branch adds it as gap
60
+ # G2/G4) - the CLI refuses host-side either way.
61
+ _ASYM_SLOTS = frozenset(
62
+ {
63
+ c.KEYREF_PIV_AUTH,
64
+ c.KEYREF_DIGITAL_SIGNATURE,
65
+ c.KEYREF_KEY_MANAGEMENT,
66
+ c.KEYREF_CARD_AUTH,
67
+ *range(c.KEYREF_RETIRED_FIRST, c.KEYREF_RETIRED_LAST + 1),
68
+ }
69
+ )
70
+
71
+ # DigestInfo prefixes for EMSA-PKCS1-v1_5 (RFC 8017 §9.2 notes).
72
+ _DIGEST_INFO = {
73
+ "sha256": bytes.fromhex("3031300D060960864801650304020105000420"),
74
+ "sha384": bytes.fromhex("3041300D060960864801650304020205000430"),
75
+ }
76
+
77
+
78
+ class KeyImportError(CryptnoxError):
79
+ """Key material unsuitable for this applet (algorithm, slot, encoding)."""
80
+
81
+ code = "key_import"
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class KeyElement:
86
+ """One CHANGE REFERENCE DATA ADMIN element: a single tagged key component."""
87
+
88
+ tag: int
89
+ value: bytes
90
+ label: str
91
+ secret: bool
92
+
93
+ def body(self) -> bytes:
94
+ return tlv.build(self.tag, self.value)
95
+
96
+
97
+ def load_private_key(raw: bytes, password: bytes | None):
98
+ """Load a PEM or DER private key (PKCS#8 or traditional encoding)."""
99
+ loader = (
100
+ serialization.load_pem_private_key
101
+ if raw.lstrip().startswith(b"-----")
102
+ else serialization.load_der_private_key
103
+ )
104
+ return loader(raw, password=password)
105
+
106
+
107
+ def load_pkcs12(raw: bytes, password: bytes | None):
108
+ """Parse a PKCS#12 container -> (private key, leaf certificate DER, extra
109
+ certificate DERs). Both a key and its certificate must be present — that is
110
+ the point of the .p12 device-setup flow."""
111
+ from cryptography.hazmat.primitives.serialization import pkcs12
112
+
113
+ key, cert, extras = pkcs12.load_key_and_certificates(raw, password)
114
+ if key is None or cert is None:
115
+ raise KeyImportError("the PKCS#12 file must contain both a private key and its certificate")
116
+ der = cert.public_bytes(serialization.Encoding.DER)
117
+ extra_ders = [c_.public_bytes(serialization.Encoding.DER) for c_ in (extras or [])]
118
+ return key, der, extra_ders
119
+
120
+
121
+ def mechanism_for_key(key) -> int:
122
+ """Map a private key to this applet's mechanism id, or refuse it."""
123
+ if isinstance(key, ec.EllipticCurvePrivateKey):
124
+ for mech, curve in _EC_BY_MECH.items():
125
+ if isinstance(key.curve, curve):
126
+ return mech
127
+ raise KeyImportError(
128
+ f"EC curve {key.curve.name} not supported; this applet takes "
129
+ "SECP256R1 (ECCP256) or SECP384R1 (ECCP384)."
130
+ )
131
+ if isinstance(key, rsa.RSAPrivateKey):
132
+ for mech, k in _RSA_MODULUS_LEN.items():
133
+ if key.key_size == k * 8:
134
+ return mech
135
+ raise KeyImportError(
136
+ f"RSA-{key.key_size} not supported; this applet takes RSA-2048 or RSA-3072."
137
+ )
138
+ raise KeyImportError(
139
+ f"unsupported key type {type(key).__name__}; this applet takes "
140
+ "ECC P-256/P-384 and RSA-2048/3072."
141
+ )
142
+
143
+
144
+ def validate_slot_mechanism(ref: int, mechanism: int) -> None:
145
+ """Refuse slots that can never hold an asymmetric key on this applet."""
146
+ if ref == c.KEYREF_ADMIN:
147
+ raise KeyImportError("slot 9B is the admin key (AES only); it cannot hold a PKI key.")
148
+ if ref == c.KEYREF_SECURE_MESSAGING:
149
+ raise KeyImportError("slot 04 is reserved for PIV secure messaging.")
150
+ if ref not in _ASYM_SLOTS:
151
+ raise KeyImportError(
152
+ f"slot {ref:02X} is not a PIV asymmetric key slot "
153
+ "(use 9A/9C/9D/9E or a retired slot 82-95)."
154
+ )
155
+ if mechanism not in (*_EC_BY_MECH, *_RSA_MODULUS_LEN):
156
+ raise KeyImportError(f"mechanism {mechanism:#04x} is not an asymmetric mechanism.")
157
+
158
+
159
+ def encode_exponent(e: int) -> bytes:
160
+ """The applet takes the RSA public exponent as exactly 3 bytes."""
161
+ if e <= 0 or e >= 1 << 24:
162
+ raise KeyImportError(f"RSA public exponent {e} does not fit the applet's 3-byte field.")
163
+ return e.to_bytes(3, "big")
164
+
165
+
166
+ def element_plan(key, *, rsa_crt: bool = True) -> list[KeyElement]:
167
+ """The ordered element sequence for a key: CLEAR, then public, then private.
168
+
169
+ The key object only flips to initialised when its last component lands, so
170
+ every intermediate element is accepted on a freshly cleared object.
171
+ """
172
+ mechanism = mechanism_for_key(key)
173
+ if isinstance(key, ec.EllipticCurvePrivateKey):
174
+ n = _EC_SCALAR_LEN[mechanism]
175
+ point = key.public_key().public_bytes(
176
+ serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
177
+ )
178
+ scalar = key.private_numbers().private_value.to_bytes(n, "big")
179
+ return [
180
+ KeyElement(ELEMENT_CLEAR, b"", "CLEAR (9F)", False),
181
+ KeyElement(ELEMENT_ECC_POINT, point, "public point (86)", False),
182
+ KeyElement(ELEMENT_ECC_SECRET, scalar, "private scalar (87)", True),
183
+ ]
184
+ k = _RSA_MODULUS_LEN[mechanism]
185
+ pub = key.public_key().public_numbers()
186
+ priv = key.private_numbers()
187
+ plan = [
188
+ KeyElement(ELEMENT_CLEAR, b"", "CLEAR (9F)", False),
189
+ KeyElement(ELEMENT_RSA_N, pub.n.to_bytes(k, "big"), "modulus N (81)", False),
190
+ KeyElement(ELEMENT_RSA_E, encode_exponent(pub.e), "exponent E (82)", False),
191
+ ]
192
+ if rsa_crt:
193
+ half = k // 2
194
+ plan += [
195
+ KeyElement(ELEMENT_RSA_P, priv.p.to_bytes(half, "big"), "P (90)", True),
196
+ KeyElement(ELEMENT_RSA_Q, priv.q.to_bytes(half, "big"), "Q (91)", True),
197
+ KeyElement(ELEMENT_RSA_DP, priv.dmp1.to_bytes(half, "big"), "dP (92)", True),
198
+ KeyElement(ELEMENT_RSA_DQ, priv.dmq1.to_bytes(half, "big"), "dQ (93)", True),
199
+ KeyElement(ELEMENT_RSA_PQ, priv.iqmp.to_bytes(half, "big"), "qInv (94)", True),
200
+ ]
201
+ else:
202
+ plan.append(KeyElement(ELEMENT_RSA_D, priv.d.to_bytes(k, "big"), "private D (83)", True))
203
+ return plan
204
+
205
+
206
+ def import_apdu(ref: int, mechanism: int, element: KeyElement) -> APDU:
207
+ """CHANGE REFERENCE DATA ADMIN carrying one key element (SCP03-wrapped later)."""
208
+ return APDU(0x00, INS_CHANGE_REFERENCE_DATA, mechanism, ref, data=element.body())
209
+
210
+
211
+ def probe_apdu(ref: int, mechanism: int) -> APDU:
212
+ """Non-destructive (slot, mechanism) probe: GENERAL AUTHENTICATE with a dummy
213
+ 1-byte challenge. 6A86 = no such key object; 6982 = exists, access (PIN) not
214
+ met; 6983 = exists, value not initialised. Never touches PIN retry counters."""
215
+ body = tlv.build_constructed(0x7C, tlv.build(0x82, b"") + tlv.build(0x81, b"\x00"))
216
+ return APDU(0x00, INS_GENERAL_AUTHENTICATE, mechanism, ref, data=body, le=256)
217
+
218
+
219
+ def digest_for_mechanism(mechanism: int):
220
+ """The hash this CLI pairs with each mechanism (SHA-256, SHA-384 for P-384)."""
221
+ return hashes.SHA384() if mechanism == 0x14 else hashes.SHA256()
222
+
223
+
224
+ def rsa_modulus_len(mechanism: int) -> int | None:
225
+ """Modulus length in bytes for an RSA mechanism, ``None`` otherwise."""
226
+ return _RSA_MODULUS_LEN.get(mechanism)
227
+
228
+
229
+ def emsa_pkcs1_v15(digest: bytes, em_len: int, *, hash_name: str | None = None) -> bytes:
230
+ """EMSA-PKCS1-v1_5 encode a message digest to ``em_len`` bytes (RFC 8017 §9.2).
231
+
232
+ The applet's RSA GENERAL AUTHENTICATE performs a raw private-key operation over
233
+ a full modulus-length block, so the host supplies the padded encoding. The hash
234
+ is inferred from the digest length unless given explicitly.
235
+ """
236
+ if hash_name is None:
237
+ hash_name = {32: "sha256", 48: "sha384"}.get(len(digest), "")
238
+ prefix = _DIGEST_INFO.get(hash_name)
239
+ if prefix is None:
240
+ raise KeyImportError(f"no DigestInfo for a {len(digest)}-byte digest")
241
+ t = prefix + digest
242
+ if em_len < len(t) + 11:
243
+ raise KeyImportError("modulus too short for EMSA-PKCS1-v1_5 encoding")
244
+ return b"\x00\x01" + b"\xff" * (em_len - len(t) - 3) + b"\x00" + t
@@ -0,0 +1,126 @@
1
+ """PIV data-object registry plus GET DATA construction / unwrapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import gzip
7
+ from dataclasses import dataclass
8
+
9
+ from cryptnox_id_cli.applets.piv import constants as c
10
+ from cryptnox_id_cli.transport.apdu import APDU
11
+ from cryptnox_id_cli.util import tlv
12
+ from cryptnox_id_cli.util.hexutil import from_hex
13
+
14
+ TAG_DATA = 0x53 # most PIV objects are wrapped in tag 0x53
15
+ TAG_DISCOVERY = 0x7E
16
+ TAG_CERT = 0x70 # certificate (possibly gzip-compressed)
17
+ TAG_CERTINFO = 0x71 # 1 byte; bit0 set => certificate is gzip-compressed
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PivObject:
22
+ name: str
23
+ oid: bytes
24
+ description: str
25
+ mandatory: bool = False
26
+ pin_protected: bool = False
27
+ slot: int | None = None
28
+
29
+ @property
30
+ def oid_hex(self) -> str:
31
+ return self.oid.hex().upper()
32
+
33
+
34
+ PIV_OBJECTS: list[PivObject] = [
35
+ PivObject("ccc", from_hex("5FC107"), "Card Capability Container", mandatory=True),
36
+ PivObject("chuid", from_hex("5FC102"), "Card Holder Unique Identifier", mandatory=True),
37
+ PivObject("discovery", bytes([TAG_DISCOVERY]), "Discovery Object", mandatory=False),
38
+ PivObject(
39
+ "auth-cert", from_hex("5FC105"), "Certificate for PIV Auth (9A)", slot=c.KEYREF_PIV_AUTH
40
+ ),
41
+ PivObject(
42
+ "sign-cert",
43
+ from_hex("5FC10A"),
44
+ "Certificate for Digital Sig (9C)",
45
+ slot=c.KEYREF_DIGITAL_SIGNATURE,
46
+ ),
47
+ PivObject(
48
+ "keymgmt-cert",
49
+ from_hex("5FC10B"),
50
+ "Certificate for Key Mgmt (9D)",
51
+ slot=c.KEYREF_KEY_MANAGEMENT,
52
+ ),
53
+ PivObject(
54
+ "card-auth-cert",
55
+ from_hex("5FC101"),
56
+ "Certificate for Card Auth (9E)",
57
+ slot=c.KEYREF_CARD_AUTH,
58
+ ),
59
+ # Key-attestation leaf, stored in the retired-slot-95 certificate container (5FC120).
60
+ # This repurposes a retired-key cert container (least likely to hold a real retired key)
61
+ # to carry the factory/issuance-time PIV key-attestation leaf — see docs/attestation-format.md.
62
+ PivObject(
63
+ "attestation-cert",
64
+ from_hex("5FC120"),
65
+ "PIV key attestation leaf (retired slot 95 container)",
66
+ slot=0x95,
67
+ ),
68
+ PivObject("security-object", from_hex("5FC106"), "Security Object"),
69
+ PivObject("key-history", from_hex("5FC10C"), "Key History Object"),
70
+ PivObject("printed", from_hex("5FC109"), "Printed Information", pin_protected=True),
71
+ PivObject("fingerprints", from_hex("5FC103"), "Cardholder Fingerprints", pin_protected=True),
72
+ PivObject("facial", from_hex("5FC108"), "Cardholder Facial Image", pin_protected=True),
73
+ PivObject("bitg", from_hex("7F61"), "Biometric Information Templates Group"),
74
+ ]
75
+
76
+ OBJECTS_BY_NAME: dict[str, PivObject] = {o.name: o for o in PIV_OBJECTS}
77
+
78
+
79
+ def object_by_name(name: str) -> PivObject | None:
80
+ return OBJECTS_BY_NAME.get(name.lower())
81
+
82
+
83
+ def get_data_apdu(oid: bytes) -> APDU:
84
+ """Build a GET DATA APDU for the given object identifier."""
85
+ tag_list = bytes([0x5C, len(oid)]) + oid
86
+ return APDU(0x00, c.INS_GET_DATA, 0x3F, 0xFF, data=tag_list, le=256)
87
+
88
+
89
+ def unwrap(oid: bytes, data: bytes) -> bytes:
90
+ """Strip the outer tag-0x53 wrapper for standard objects; pass discovery through."""
91
+ if oid == bytes([TAG_DISCOVERY]):
92
+ return data
93
+ try:
94
+ tlvs = tlv.parse(data)
95
+ except ValueError:
96
+ return data
97
+ node = tlv.find(tlvs, TAG_DATA)
98
+ return node.value if node is not None else data
99
+
100
+
101
+ def extract_certificate(object_value: bytes) -> bytes | None:
102
+ """Pull the DER certificate out of a PIV certificate container (tag 0x70),
103
+ decompressing it if the CertInfo (tag 0x71) flags gzip. ``object_value`` is the
104
+ already-unwrapped object content."""
105
+ try:
106
+ nodes = tlv.parse(object_value)
107
+ except ValueError:
108
+ return None
109
+ # Look only at the container's own top-level TLVs. tlv.find descends depth-first,
110
+ # and tag 0x70 is constructed, so a find() for 0x71 could match a node *inside*
111
+ # the certificate's parsed DER and read the wrong gzip flag.
112
+ cert = next((n for n in nodes if n.tag == TAG_CERT), None)
113
+ if cert is None:
114
+ return None
115
+ der = cert.value
116
+ info = next((n for n in nodes if n.tag == TAG_CERTINFO), None)
117
+ if info is not None and info.value and (info.value[0] & 0x01):
118
+ with contextlib.suppress(Exception):
119
+ der = gzip.decompress(der)
120
+ return der
121
+
122
+
123
+ def wrap_certificate(der: bytes, *, compressed: bool = False) -> bytes:
124
+ """Build a PIV certificate container value: 70 <cert> 71 01 <info> FE 00."""
125
+ info = 0x01 if compressed else 0x00
126
+ return tlv.build(TAG_CERT, der) + tlv.build(TAG_CERTINFO, bytes([info])) + tlv.build(0xFE, b"")
@@ -0,0 +1,138 @@
1
+ """PIV personalization wire grammar: set verifier (PIN/PUK) values, on-card key
2
+ generation, and writing data objects.
3
+
4
+ CHANGE REFERENCE DATA ADMIN (INS 24), GENERATE ASYMMETRIC KEYPAIR (INS 47) and
5
+ PUT DATA (INS DB) per SP 800-73 / the applet's perso-grammar doc. The exact
6
+ CHANGE-REF-DATA value layout is marked "to verify" in that doc, so it is
7
+ validated against the live card.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from cryptography.hazmat.primitives import serialization
13
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
14
+
15
+ from cryptnox_id_cli.transport.apdu import APDU
16
+ from cryptnox_id_cli.util import tlv
17
+
18
+ INS_CHANGE_REFERENCE_DATA = 0x24
19
+ INS_GENERATE_ASYMMETRIC = 0x47
20
+ INS_PUT_DATA = 0xDB
21
+
22
+ P1_PIN_VALUE = 0xFF # CHANGE REF DATA ADMIN P1 for a PIN/PUK reference (vs a key mechanism)
23
+
24
+ TAG_GENERATE_REQUEST = 0xAC
25
+ TAG_MECHANISM = 0x80
26
+ TAG_PUBKEY_TEMPLATE = 0x7F49
27
+ TAG_RSA_MODULUS = 0x81
28
+ TAG_RSA_EXPONENT = 0x82
29
+ TAG_ECC_POINT = 0x86
30
+
31
+ TAG_DATA = 0x53
32
+ TAG_TAG_LIST = 0x5C
33
+ TAG_DISCOVERY = 0x7E # the one PIV object written (and read) bare, never 53-wrapped
34
+
35
+ # Curves by mechanism id.
36
+ _EC_CURVES = {0x11: ec.SECP256R1, 0x14: ec.SECP384R1}
37
+
38
+ # Asymmetric algorithm name -> mechanism id (those this applet supports).
39
+ ALGORITHMS = {"ECCP256": 0x11, "ECCP384": 0x14, "RSA2048": 0x07, "RSA3072": 0x05}
40
+
41
+
42
+ def pad_pin(value: bytes, length: int = 8) -> bytes:
43
+ """PIV PIN/PUK values are padded to 8 bytes with 0xFF."""
44
+ if len(value) > length:
45
+ raise ValueError(f"PIN/PUK value exceeds {length} bytes")
46
+ return bytes(value) + b"\xff" * (length - len(value))
47
+
48
+
49
+ def set_verifier_value_apdu(ref: int, padded_value: bytes) -> APDU:
50
+ """CHANGE REFERENCE DATA ADMIN to set a PIN/PUK value (P1=0xFF, P2=ref).
51
+
52
+ Wrapped by the SCP03 layer (admin). ``padded_value`` should already be padded.
53
+ """
54
+ return APDU(0x00, INS_CHANGE_REFERENCE_DATA, P1_PIN_VALUE, ref, data=padded_value)
55
+
56
+
57
+ def generate_keypair_apdu(slot: int, mechanism: int) -> APDU:
58
+ """GENERATE ASYMMETRIC KEYPAIR: request AC{80 mechanism} for the given slot."""
59
+ request = tlv.build_constructed(
60
+ TAG_GENERATE_REQUEST, tlv.build(TAG_MECHANISM, bytes([mechanism]))
61
+ )
62
+ return APDU(0x00, INS_GENERATE_ASYMMETRIC, 0x00, slot, data=request, le=256)
63
+
64
+
65
+ def parse_public_key(mechanism: int, response: bytes):
66
+ """Parse the 7F49 public-key template from a GENERATE response into a
67
+ cryptography public-key object (EC or RSA)."""
68
+ nodes = tlv.parse(response)
69
+ template = tlv.find(nodes, TAG_PUBKEY_TEMPLATE)
70
+ scope = template.children if (template and template.children) else nodes
71
+ if mechanism in _EC_CURVES:
72
+ point = tlv.find(scope, TAG_ECC_POINT)
73
+ if point is None:
74
+ raise ValueError("no EC point (tag 86) in GENERATE response")
75
+ curve = _EC_CURVES[mechanism]()
76
+ return ec.EllipticCurvePublicKey.from_encoded_point(curve, point.value)
77
+ modulus = tlv.find(scope, TAG_RSA_MODULUS)
78
+ exponent = tlv.find(scope, TAG_RSA_EXPONENT)
79
+ if modulus is None or exponent is None:
80
+ raise ValueError("no RSA modulus/exponent (tags 81/82) in GENERATE response")
81
+ n = int.from_bytes(modulus.value, "big")
82
+ e = int.from_bytes(exponent.value, "big")
83
+ return rsa.RSAPublicNumbers(e, n).public_key()
84
+
85
+
86
+ def public_key_pem(public_key) -> bytes:
87
+ return public_key.public_bytes(
88
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
89
+ )
90
+
91
+
92
+ def put_data_body(oid: bytes, object_data: bytes) -> bytes:
93
+ """The PUT DATA command body: 5C{oid} 53{object_data} for standard objects.
94
+
95
+ The Discovery Object is the documented exception (SP 800-73-4): it travels as the
96
+ bare 7E TLV itself, no tag list and no 53 wrapper, and the applet's PUT DATA has a
97
+ dedicated branch keyed on the first CDATA byte being 7E. Wrapping it like a
98
+ standard object makes the applet store and echo the wrapper, producing a malformed
99
+ object on the wire - the read side (objects.unwrap) has always special-cased 7E;
100
+ this is the matching write-side case.
101
+ """
102
+ if oid == bytes([TAG_DISCOVERY]):
103
+ return object_data
104
+ return tlv.build(TAG_TAG_LIST, oid) + tlv.build(TAG_DATA, object_data)
105
+
106
+
107
+ def put_data_apdu(oid: bytes, object_data: bytes) -> APDU:
108
+ """PUT DATA (standard): write a data object's content into a container (single APDU)."""
109
+ return APDU(0x00, INS_PUT_DATA, 0x3F, 0xFF, data=put_data_body(oid, object_data))
110
+
111
+
112
+ INS_GENERAL_AUTHENTICATE = 0x87
113
+ TAG_DYNAMIC_AUTH = 0x7C
114
+ TAG_AUTH_CHALLENGE = 0x81
115
+ TAG_AUTH_RESPONSE = 0x82
116
+
117
+
118
+ def general_authenticate_sign_apdu(slot: int, mechanism: int, digest: bytes) -> APDU:
119
+ """GENERAL AUTHENTICATE to sign a pre-computed digest with the slot's key.
120
+
121
+ Request: 7C { 82 (empty response placeholder), 81 <digest> }.
122
+ Response: 7C { 82 <signature> }. Requires the slot's access (PIN) to be met.
123
+ """
124
+ body = tlv.build_constructed(
125
+ TAG_DYNAMIC_AUTH, tlv.build(TAG_AUTH_RESPONSE, b"") + tlv.build(TAG_AUTH_CHALLENGE, digest)
126
+ )
127
+ return APDU(0x00, INS_GENERAL_AUTHENTICATE, mechanism, slot, data=body, le=256)
128
+
129
+
130
+ def parse_sign_response(response: bytes) -> bytes:
131
+ """Extract the signature (7C → 82) from a GENERAL AUTHENTICATE response."""
132
+ nodes = tlv.parse(response)
133
+ template = tlv.find(nodes, TAG_DYNAMIC_AUTH)
134
+ scope = template.children if (template and template.children) else nodes
135
+ sig = tlv.find(scope, TAG_AUTH_RESPONSE)
136
+ if sig is None:
137
+ raise ValueError("no signature (tag 82) in GENERAL AUTHENTICATE response")
138
+ return sig.value