cryptnox-id-cli 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cryptnox_id_cli/__init__.py +22 -0
- cryptnox_id_cli/__main__.py +6 -0
- cryptnox_id_cli/applets/__init__.py +2 -0
- cryptnox_id_cli/applets/fido/__init__.py +7 -0
- cryptnox_id_cli/applets/fido/authdata.py +81 -0
- cryptnox_id_cli/applets/fido/constants.py +155 -0
- cryptnox_id_cli/applets/fido/ctap.py +430 -0
- cryptnox_id_cli/applets/fido/errors.py +90 -0
- cryptnox_id_cli/applets/fido/pinproto.py +166 -0
- cryptnox_id_cli/applets/genuine/__init__.py +12 -0
- cryptnox_id_cli/applets/genuine/constants.py +32 -0
- cryptnox_id_cli/applets/genuine/genuine.py +85 -0
- cryptnox_id_cli/applets/genuine/verify.py +150 -0
- cryptnox_id_cli/applets/mifare/__init__.py +10 -0
- cryptnox_id_cli/applets/mifare/desfire.py +353 -0
- cryptnox_id_cli/applets/mifare/ev2.py +421 -0
- cryptnox_id_cli/applets/piv/__init__.py +9 -0
- cryptnox_id_cli/applets/piv/admin.py +182 -0
- cryptnox_id_cli/applets/piv/apt.py +58 -0
- cryptnox_id_cli/applets/piv/constants.py +66 -0
- cryptnox_id_cli/applets/piv/keyimport.py +244 -0
- cryptnox_id_cli/applets/piv/objects.py +126 -0
- cryptnox_id_cli/applets/piv/perso.py +138 -0
- cryptnox_id_cli/applets/piv/piv.py +171 -0
- cryptnox_id_cli/applets/piv/preperso.py +174 -0
- cryptnox_id_cli/applets/piv/profiles.py +486 -0
- cryptnox_id_cli/applets/piv/slots.py +44 -0
- cryptnox_id_cli/cli/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/apdu.py +123 -0
- cryptnox_id_cli/cli/commands/doctor.py +144 -0
- cryptnox_id_cli/cli/commands/factory.py +312 -0
- cryptnox_id_cli/cli/commands/fido.py +729 -0
- cryptnox_id_cli/cli/commands/genuine.py +192 -0
- cryptnox_id_cli/cli/commands/info.py +94 -0
- cryptnox_id_cli/cli/commands/mifare.py +998 -0
- cryptnox_id_cli/cli/commands/piv.py +2558 -0
- cryptnox_id_cli/cli/commands/readers.py +64 -0
- cryptnox_id_cli/cli/commands/report.py +217 -0
- cryptnox_id_cli/cli/commands/shell.py +130 -0
- cryptnox_id_cli/cli/context.py +71 -0
- cryptnox_id_cli/cli/dryrun.py +181 -0
- cryptnox_id_cli/cli/main.py +117 -0
- cryptnox_id_cli/crypto/__init__.py +1 -0
- cryptnox_id_cli/crypto/attestation.py +169 -0
- cryptnox_id_cli/crypto/csr.py +135 -0
- cryptnox_id_cli/crypto/piv_objects.py +79 -0
- cryptnox_id_cli/crypto/x509util.py +37 -0
- cryptnox_id_cli/output/__init__.py +5 -0
- cryptnox_id_cli/output/render.py +106 -0
- cryptnox_id_cli/secrets/__init__.py +5 -0
- cryptnox_id_cli/secrets/redaction.py +141 -0
- cryptnox_id_cli/secrets/resolver.py +93 -0
- cryptnox_id_cli/state/__init__.py +19 -0
- cryptnox_id_cli/state/detector.py +273 -0
- cryptnox_id_cli/state/model.py +147 -0
- cryptnox_id_cli/transport/__init__.py +28 -0
- cryptnox_id_cli/transport/apdu.py +72 -0
- cryptnox_id_cli/transport/elevation.py +165 -0
- cryptnox_id_cli/transport/errors.py +185 -0
- cryptnox_id_cli/transport/pcsc.py +351 -0
- cryptnox_id_cli/transport/scp02.py +247 -0
- cryptnox_id_cli/transport/scp03.py +213 -0
- cryptnox_id_cli/trust/__init__.py +90 -0
- cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
- cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
- cryptnox_id_cli/util/__init__.py +1 -0
- cryptnox_id_cli/util/hexutil.py +36 -0
- cryptnox_id_cli/util/tlv.py +144 -0
- cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
- cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
- cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
- cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
- cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
- cryptnox_id_cli-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|