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,12 @@
|
|
|
1
|
+
"""Cryptnox Genuineness / attestation applet (RID A0000010, AID A000001000024701).
|
|
2
|
+
|
|
3
|
+
Read-only client for the on-card attestation applet: SELECT, GET INFO, GET CERT
|
|
4
|
+
(leaf / issuer, free-read even when fused) and ATTEST (sign a host nonce with the
|
|
5
|
+
on-card device key). These are the KDF-free genuineness checks — they need no ISD /
|
|
6
|
+
PIV-SSD secret, so this tool can run them without the factory HSM. See
|
|
7
|
+
``docs/genuineness.md``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from cryptnox_id_cli.applets.genuine.genuine import GenuinenessApplet, GenuinenessInfo
|
|
11
|
+
|
|
12
|
+
__all__ = ["GenuinenessApplet", "GenuinenessInfo"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Constants for the Cryptnox Genuineness / attestation applet.
|
|
2
|
+
|
|
3
|
+
Values mirror the factory tool (``genuineness_tools/factory_perso.py``): Cryptnox
|
|
4
|
+
RID ``A0000010``, applet instance AID ``A000001000024701``. The applet is
|
|
5
|
+
**contact-only** (enforced on-card), so it does not answer over a contactless
|
|
6
|
+
interface — SELECT there returns 6A82.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Applet instance AID and package AID (RID A0000010 = Cryptnox).
|
|
12
|
+
GENUINE_AID = bytes.fromhex("A000001000024701")
|
|
13
|
+
GENUINE_PKG = bytes.fromhex("A0000010000247")
|
|
14
|
+
|
|
15
|
+
# Instruction bytes (CLA 0x80). GET INFO / GET CERT / ATTEST are open (no-auth)
|
|
16
|
+
# reads; GENERATE / PUT CERT / LOCK are factory-only over an SCP02 C-MAC channel
|
|
17
|
+
# and are intentionally NOT exposed by this read-only CLI.
|
|
18
|
+
INS_GET_INFO = 0x01
|
|
19
|
+
INS_GET_CERT = 0x02
|
|
20
|
+
INS_ATTEST = 0x04
|
|
21
|
+
INS_GENERATE = 0x10 # factory only (not used here)
|
|
22
|
+
INS_PUT_CERT = 0x12 # factory only (not used here)
|
|
23
|
+
INS_LOCK = 0x1E # factory only (not used here)
|
|
24
|
+
|
|
25
|
+
# GET CERT P1 selectors.
|
|
26
|
+
CERT_LEAF = 0x00 # the on-card device leaf (signed by the Cryptnox Genuineness CA)
|
|
27
|
+
CERT_INTERMEDIATE = 0x01 # the issuer cert stored alongside the leaf
|
|
28
|
+
|
|
29
|
+
# ATTEST domain-separation label. The card returns ECDSA(SHA-256) over LABEL||nonce
|
|
30
|
+
# with the device private key; the host verifies it against the leaf's public key.
|
|
31
|
+
ATTEST_LABEL = b"CRYPTNOX-GENUINE-v1"
|
|
32
|
+
ATTEST_NONCE_LEN = 32
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Read-only client for the Cryptnox Genuineness / attestation applet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from cryptnox_id_cli.applets.genuine import constants as c
|
|
8
|
+
from cryptnox_id_cli.transport.apdu import APDU
|
|
9
|
+
from cryptnox_id_cli.transport.errors import AppletNotFoundError, StatusWordError
|
|
10
|
+
from cryptnox_id_cli.transport.pcsc import CardSession
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GenuinenessInfo:
|
|
15
|
+
"""The 4-byte GET INFO blob, kept raw (the applet's field layout is not
|
|
16
|
+
documented here — we report what the card returns and never guess a verdict
|
|
17
|
+
from it)."""
|
|
18
|
+
|
|
19
|
+
raw: bytes
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def hex(self) -> str:
|
|
23
|
+
return self.raw.hex().upper()
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, object]:
|
|
26
|
+
return {"raw": self.hex}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GenuinenessApplet:
|
|
30
|
+
def __init__(self, session: CardSession) -> None:
|
|
31
|
+
self.session = session
|
|
32
|
+
|
|
33
|
+
# -- selection ---------------------------------------------------------- #
|
|
34
|
+
def select(self) -> None:
|
|
35
|
+
"""SELECT the applet. Raises AppletNotFoundError on 6A82 (also returned on a
|
|
36
|
+
contactless interface, where this contact-only applet does not answer)."""
|
|
37
|
+
resp = self.session.transmit(
|
|
38
|
+
APDU(0x00, 0xA4, 0x04, 0x00, data=c.GENUINE_AID, le=256), context="SELECT GENUINE"
|
|
39
|
+
)
|
|
40
|
+
# Same multi-applet quirk as PIV: the first case-4 ISO SELECT after a DESFire
|
|
41
|
+
# native session is rejected with 6700; retry once as case-3 (no Le).
|
|
42
|
+
if resp.sw == 0x6700:
|
|
43
|
+
resp = self.session.transmit(
|
|
44
|
+
APDU(0x00, 0xA4, 0x04, 0x00, data=c.GENUINE_AID), context="SELECT GENUINE (no Le)"
|
|
45
|
+
)
|
|
46
|
+
# 6A82 = AID not found (card manager). 6D00 = INS A4 not supported by whatever
|
|
47
|
+
# applet happens to be selected (e.g. U2F left selected by a preceding FIDO probe):
|
|
48
|
+
# the genuineness AID is not selectable there, indistinguishable from absent. A real
|
|
49
|
+
# genuineness applet answers its own SELECT with 9000, so neither can be a present one.
|
|
50
|
+
if resp.sw in (0x6A82, 0x6D00):
|
|
51
|
+
raise AppletNotFoundError("Genuineness applet not found on this card/interface.")
|
|
52
|
+
if not resp.ok:
|
|
53
|
+
raise StatusWordError(resp.sw1, resp.sw2, context="SELECT GENUINE")
|
|
54
|
+
|
|
55
|
+
def try_select(self) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
self.select()
|
|
58
|
+
return True
|
|
59
|
+
except AppletNotFoundError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
# -- open (no-auth) reads ----------------------------------------------- #
|
|
63
|
+
def get_info(self) -> GenuinenessInfo | None:
|
|
64
|
+
resp = self.session.transmit(
|
|
65
|
+
APDU(0x80, c.INS_GET_INFO, 0x00, 0x00, le=256), context="GENUINE GET INFO"
|
|
66
|
+
)
|
|
67
|
+
return GenuinenessInfo(resp.data) if resp.ok else None
|
|
68
|
+
|
|
69
|
+
def get_cert(self, which: int = c.CERT_LEAF) -> bytes | None:
|
|
70
|
+
"""Read a stored DER certificate (CERT_LEAF / CERT_INTERMEDIATE). Free-read —
|
|
71
|
+
works even on a fused card. Returns None when absent or unreadable."""
|
|
72
|
+
resp = self.session.transmit(
|
|
73
|
+
APDU(0x80, c.INS_GET_CERT, which, 0x00, le=65536), context="GENUINE GET CERT"
|
|
74
|
+
)
|
|
75
|
+
return resp.data if (resp.ok and resp.data) else None
|
|
76
|
+
|
|
77
|
+
def attest(self, nonce: bytes) -> bytes:
|
|
78
|
+
"""ATTEST a host nonce: the card returns ECDSA(SHA-256) over
|
|
79
|
+
``ATTEST_LABEL || nonce`` signed by the on-card device private key."""
|
|
80
|
+
resp = self.session.transmit(
|
|
81
|
+
APDU(0x80, c.INS_ATTEST, 0x00, 0x00, data=nonce), context="GENUINE ATTEST"
|
|
82
|
+
)
|
|
83
|
+
if not resp.ok:
|
|
84
|
+
raise StatusWordError(resp.sw1, resp.sw2, context="GENUINE ATTEST")
|
|
85
|
+
return resp.data
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Offline verification of the genuineness applet's attestation.
|
|
2
|
+
|
|
3
|
+
Two independent, KDF-free checks (neither needs the ISD / PIV-SSD secret, so this
|
|
4
|
+
tool can run both without the factory HSM):
|
|
5
|
+
|
|
6
|
+
* **proof of possession** — the card ATTESTs a fresh host nonce; the returned ECDSA
|
|
7
|
+
signature is verified against the on-card leaf's public key. This proves the card
|
|
8
|
+
physically holds the device private key *right now* (a copied certificate cannot
|
|
9
|
+
fake it).
|
|
10
|
+
* **certificate chain** — the leaf chains ``leaf -> Genuineness CA -> ... -> pinned
|
|
11
|
+
Cryptnox root`` using the pinned trust anchors (:mod:`cryptnox_id_cli.trust`).
|
|
12
|
+
Without a pinned root this reports "cannot verify" — it never silently passes.
|
|
13
|
+
|
|
14
|
+
What this does NOT do: the ISD-KDF and PIV-SSD-KVN2 genuineness methods, which
|
|
15
|
+
require the per-card HSM-derived secret keys. Those are out of scope for a tool with
|
|
16
|
+
no KDF access; absence of that check is stated in the result, not hidden.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from cryptography import x509
|
|
24
|
+
from cryptography.exceptions import InvalidSignature
|
|
25
|
+
from cryptography.hazmat.primitives import hashes
|
|
26
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
27
|
+
|
|
28
|
+
from cryptnox_id_cli.applets.genuine import constants as c
|
|
29
|
+
from cryptnox_id_cli.crypto.attestation import AttestationResult, verify_attestation_chain
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class GenuinenessResult:
|
|
34
|
+
"""Outcome of the read-only genuineness verification — JSON-friendly."""
|
|
35
|
+
|
|
36
|
+
attested: bool # card produced a signature that verifies against its leaf key
|
|
37
|
+
attest_detail: str
|
|
38
|
+
chain: AttestationResult | None # None if no leaf cert was available to chain
|
|
39
|
+
leaf_subject: str | None = None
|
|
40
|
+
notes: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def genuine(self) -> bool:
|
|
44
|
+
"""Genuine iff the card proved possession AND the chain anchored at a pinned
|
|
45
|
+
Cryptnox root. Proof-of-possession alone is necessary but not sufficient."""
|
|
46
|
+
return self.attested and bool(self.chain and self.chain.verified)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, object]:
|
|
49
|
+
return {
|
|
50
|
+
"genuine": self.genuine,
|
|
51
|
+
"proof_of_possession": self.attested,
|
|
52
|
+
"proof_detail": self.attest_detail,
|
|
53
|
+
"leaf_subject": self.leaf_subject,
|
|
54
|
+
"chain": self.chain.to_dict() if self.chain else None,
|
|
55
|
+
"notes": self.notes,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_attestation_signature(leaf_der: bytes, nonce: bytes, signature: bytes) -> bool:
|
|
60
|
+
"""True iff ``signature`` is a valid ECDSA(SHA-256) over ``ATTEST_LABEL || nonce``
|
|
61
|
+
under the leaf certificate's public key."""
|
|
62
|
+
leaf = x509.load_der_x509_certificate(leaf_der)
|
|
63
|
+
pub = leaf.public_key()
|
|
64
|
+
if not isinstance(pub, ec.EllipticCurvePublicKey):
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
pub.verify(signature, c.ATTEST_LABEL + nonce, ec.ECDSA(hashes.SHA256()))
|
|
68
|
+
return True
|
|
69
|
+
except InvalidSignature:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _order_intermediates(leaf_der: bytes, pool_ders: list[bytes]) -> list[bytes]:
|
|
74
|
+
"""Order a pool of candidate intermediate CAs into a leaf->root chain, following
|
|
75
|
+
issuer links and dropping duplicates / self-issued roots. ``verify_attestation_chain``
|
|
76
|
+
needs the intermediates pre-ordered; the pool (card issuer + pinned + --anchors) is
|
|
77
|
+
not, so we walk it here."""
|
|
78
|
+
pool = {der: x509.load_der_x509_certificate(der) for der in pool_ders}
|
|
79
|
+
ordered: list[bytes] = []
|
|
80
|
+
current = x509.load_der_x509_certificate(leaf_der)
|
|
81
|
+
used: set[bytes] = set()
|
|
82
|
+
while True:
|
|
83
|
+
nxt = next(
|
|
84
|
+
(
|
|
85
|
+
der
|
|
86
|
+
for der, cert in pool.items()
|
|
87
|
+
if der not in used
|
|
88
|
+
and cert.subject == current.issuer
|
|
89
|
+
and cert.subject != cert.issuer # a self-issued CA is a root, not an intermediate
|
|
90
|
+
),
|
|
91
|
+
None,
|
|
92
|
+
)
|
|
93
|
+
if nxt is None:
|
|
94
|
+
break
|
|
95
|
+
ordered.append(nxt)
|
|
96
|
+
used.add(nxt)
|
|
97
|
+
current = pool[nxt]
|
|
98
|
+
return ordered
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verify_genuineness(
|
|
102
|
+
*,
|
|
103
|
+
leaf_der: bytes | None,
|
|
104
|
+
nonce: bytes,
|
|
105
|
+
signature: bytes | None,
|
|
106
|
+
card_issuer_der: bytes | None,
|
|
107
|
+
roots: list[bytes],
|
|
108
|
+
intermediates: list[bytes],
|
|
109
|
+
) -> GenuinenessResult:
|
|
110
|
+
"""Combine proof-of-possession + chain verification into one result.
|
|
111
|
+
|
|
112
|
+
``card_issuer_der`` is the issuer cert read off the card (GET CERT intermediate);
|
|
113
|
+
it joins the pinned ``intermediates`` in a pool that is then ordered leaf->root so
|
|
114
|
+
the chain can climb even when only the root is pinned. ``roots``/``intermediates``
|
|
115
|
+
are the pinned anchors from the trust store (plus any the caller loaded)."""
|
|
116
|
+
notes: list[str] = []
|
|
117
|
+
if leaf_der is None:
|
|
118
|
+
return GenuinenessResult(
|
|
119
|
+
attested=False,
|
|
120
|
+
attest_detail="no device leaf certificate on card (applet not personalized)",
|
|
121
|
+
chain=None,
|
|
122
|
+
notes=["Genuineness applet present but carries no device leaf certificate."],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
leaf_subject = x509.load_der_x509_certificate(leaf_der).subject.rfc4514_string()
|
|
126
|
+
|
|
127
|
+
if signature is None:
|
|
128
|
+
attested, detail = False, "ATTEST failed (card returned no signature)"
|
|
129
|
+
elif verify_attestation_signature(leaf_der, nonce, signature):
|
|
130
|
+
attested = True
|
|
131
|
+
detail = f"card signed the {len(nonce)}-byte nonce; verifies against leaf key"
|
|
132
|
+
else:
|
|
133
|
+
attested, detail = False, "ATTEST signature did NOT verify against the leaf key"
|
|
134
|
+
|
|
135
|
+
pool = ([card_issuer_der] if card_issuer_der else []) + list(intermediates)
|
|
136
|
+
inter = _order_intermediates(leaf_der, pool)
|
|
137
|
+
chain = verify_attestation_chain(leaf_der, inter, roots)
|
|
138
|
+
if not roots:
|
|
139
|
+
notes.append(
|
|
140
|
+
"No pinned Cryptnox genuineness root bundled - chain cannot be anchored. "
|
|
141
|
+
"Drop the root PEM into src/cryptnox_id_cli/trust/genuine/ (or pass "
|
|
142
|
+
"--anchors DIR) to complete the chain."
|
|
143
|
+
)
|
|
144
|
+
return GenuinenessResult(
|
|
145
|
+
attested=attested,
|
|
146
|
+
attest_detail=detail,
|
|
147
|
+
chain=chain,
|
|
148
|
+
leaf_subject=leaf_subject,
|
|
149
|
+
notes=notes,
|
|
150
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""MIFARE DESFire EV2 support (contactless).
|
|
2
|
+
|
|
3
|
+
The DESFire function is the card's default (MIFARE) applet, reached over a
|
|
4
|
+
contactless reader with no JavaCard applet selected. Native commands are sent
|
|
5
|
+
ISO-7816-wrapped (CLA 0x90); multi-frame responses chain via 0x91AF.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from cryptnox_id_cli.applets.mifare.desfire import DesfireError, DesfireTransport
|
|
9
|
+
|
|
10
|
+
__all__ = ["DesfireError", "DesfireTransport"]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""MIFARE DESFire EV2 native command transport + read-only parsers.
|
|
2
|
+
|
|
3
|
+
Native commands are sent ISO-7816-wrapped: ``90 <cmd> 00 00 [Lc <data>] 00``.
|
|
4
|
+
Responses end in ``91 <status>``; ``0x91AF`` means "additional frame", fetched
|
|
5
|
+
with ``90 AF 00 00 00`` until the status is final.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from cryptnox_id_cli.transport.errors import CryptnoxError
|
|
13
|
+
from cryptnox_id_cli.transport.pcsc import CardSession
|
|
14
|
+
|
|
15
|
+
# Native command codes.
|
|
16
|
+
CMD_GET_VERSION = 0x60
|
|
17
|
+
CMD_ADDITIONAL_FRAME = 0xAF
|
|
18
|
+
CMD_GET_FREE_MEMORY = 0x6E
|
|
19
|
+
CMD_GET_APPLICATION_IDS = 0x6A
|
|
20
|
+
CMD_GET_DF_NAMES = 0x6D
|
|
21
|
+
CMD_SELECT_APPLICATION = 0x5A
|
|
22
|
+
CMD_GET_FILE_IDS = 0x6F
|
|
23
|
+
CMD_GET_FILE_SETTINGS = 0xF5
|
|
24
|
+
CMD_CHANGE_FILE_SETTINGS = 0x5F
|
|
25
|
+
CMD_GET_KEY_SETTINGS = 0x45
|
|
26
|
+
CMD_CREATE_APPLICATION = 0xCA
|
|
27
|
+
CMD_DELETE_APPLICATION = 0xDA
|
|
28
|
+
CMD_FORMAT_PICC = 0xFC
|
|
29
|
+
CMD_CREATE_STD_DATA_FILE = 0xCD
|
|
30
|
+
CMD_WRITE_DATA = 0x3D
|
|
31
|
+
CMD_READ_DATA = 0xBD
|
|
32
|
+
CMD_CREATE_VALUE_FILE = 0xCC
|
|
33
|
+
CMD_GET_VALUE = 0x6C
|
|
34
|
+
CMD_CREDIT = 0x0C
|
|
35
|
+
CMD_DEBIT = 0xDC
|
|
36
|
+
CMD_COMMIT_TXN = 0xC7
|
|
37
|
+
CMD_ABORT_TXN = 0xA7
|
|
38
|
+
CMD_CREATE_LINEAR_RECORD_FILE = 0xC1
|
|
39
|
+
CMD_CREATE_CYCLIC_RECORD_FILE = 0xC0
|
|
40
|
+
CMD_WRITE_RECORD = 0x3B
|
|
41
|
+
CMD_READ_RECORDS = 0xBB
|
|
42
|
+
CMD_CLEAR_RECORD_FILE = 0xEB
|
|
43
|
+
CMD_CHANGE_KEY = 0xC4
|
|
44
|
+
|
|
45
|
+
STATUS_OK = 0x00
|
|
46
|
+
STATUS_ADDITIONAL_FRAME = 0xAF
|
|
47
|
+
STATUS_LENGTH_ERROR = 0x7E # card rejected the command/payload length
|
|
48
|
+
|
|
49
|
+
_STATUS = {
|
|
50
|
+
0x00: "OPERATION_OK",
|
|
51
|
+
0x0C: "NO_CHANGES",
|
|
52
|
+
0x0E: "OUT_OF_EEPROM_ERROR",
|
|
53
|
+
0x1C: "ILLEGAL_COMMAND_CODE",
|
|
54
|
+
0x1E: "INTEGRITY_ERROR",
|
|
55
|
+
0x40: "NO_SUCH_KEY",
|
|
56
|
+
0x7E: "LENGTH_ERROR",
|
|
57
|
+
0x9D: "PERMISSION_DENIED",
|
|
58
|
+
0x9E: "PARAMETER_ERROR",
|
|
59
|
+
0xA0: "APPLICATION_NOT_FOUND",
|
|
60
|
+
0xA1: "APPL_INTEGRITY_ERROR",
|
|
61
|
+
0xAE: "AUTHENTICATION_ERROR",
|
|
62
|
+
0xAF: "ADDITIONAL_FRAME",
|
|
63
|
+
0xBE: "BOUNDARY_ERROR",
|
|
64
|
+
0xC1: "CARD_INTEGRITY_ERROR",
|
|
65
|
+
0xCA: "COMMAND_ABORTED",
|
|
66
|
+
0xCD: "CARD_DISABLED_ERROR",
|
|
67
|
+
0xCE: "COUNT_ERROR",
|
|
68
|
+
0xDE: "DUPLICATE_ERROR",
|
|
69
|
+
0xEE: "EEPROM_ERROR",
|
|
70
|
+
0xF0: "FILE_NOT_FOUND",
|
|
71
|
+
0xF1: "FILE_INTEGRITY_ERROR",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def status_name(status: int) -> str:
|
|
76
|
+
return _STATUS.get(status, f"0x{status:02X}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DesfireError(CryptnoxError):
|
|
80
|
+
code = "desfire_error"
|
|
81
|
+
exit_code = 9
|
|
82
|
+
|
|
83
|
+
def __init__(self, status: int, context: str | None = None) -> None:
|
|
84
|
+
self.status = status
|
|
85
|
+
prefix = f"{context}: " if context else ""
|
|
86
|
+
super().__init__(f"{prefix}DESFire status {status_name(status)} (0x{status:02X}).")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DesfireNotSelectedError(CryptnoxError):
|
|
90
|
+
"""The card did not answer as a DESFire (MIFARE not the selected default applet)."""
|
|
91
|
+
|
|
92
|
+
code = "desfire_not_selected"
|
|
93
|
+
exit_code = 5
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DesfireFrameTooLargeError(CryptnoxError):
|
|
97
|
+
"""A native command would exceed the single short-frame limit (Lc > 255)."""
|
|
98
|
+
|
|
99
|
+
code = "desfire_frame_too_large"
|
|
100
|
+
exit_code = 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class DesfireVersion:
|
|
105
|
+
hw_vendor: int
|
|
106
|
+
hw_type: int
|
|
107
|
+
hw_subtype: int
|
|
108
|
+
hw_major: int
|
|
109
|
+
hw_minor: int
|
|
110
|
+
hw_storage: int
|
|
111
|
+
hw_protocol: int
|
|
112
|
+
sw_vendor: int
|
|
113
|
+
sw_type: int
|
|
114
|
+
sw_subtype: int
|
|
115
|
+
sw_major: int
|
|
116
|
+
sw_minor: int
|
|
117
|
+
sw_storage: int
|
|
118
|
+
sw_protocol: int
|
|
119
|
+
uid: bytes
|
|
120
|
+
batch: bytes
|
|
121
|
+
cw_production: int
|
|
122
|
+
year_production: int
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _storage(code: int) -> int:
|
|
126
|
+
# n>>1 is the exponent; the low bit flags "between this and the next size".
|
|
127
|
+
return 1 << (code >> 1)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def storage_bytes(self) -> int:
|
|
131
|
+
return self._storage(self.hw_storage)
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, object]:
|
|
134
|
+
return {
|
|
135
|
+
"vendor": "NXP" if self.hw_vendor == 0x04 else f"0x{self.hw_vendor:02X}",
|
|
136
|
+
"hardware_version": f"{self.hw_major}.{self.hw_minor}",
|
|
137
|
+
"software_version": f"{self.sw_major}.{self.sw_minor}",
|
|
138
|
+
"storage_bytes": self.storage_bytes,
|
|
139
|
+
"protocol": f"0x{self.hw_protocol:02X}",
|
|
140
|
+
"uid": self.uid.hex().upper(),
|
|
141
|
+
"batch": self.batch.hex().upper(),
|
|
142
|
+
"production": f"week {self.cw_production:02X}/year {self.year_production:02X}",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_version(data: bytes) -> DesfireVersion:
|
|
147
|
+
if len(data) < 28:
|
|
148
|
+
raise ValueError(f"GetVersion response too short ({len(data)} bytes)")
|
|
149
|
+
return DesfireVersion(
|
|
150
|
+
hw_vendor=data[0],
|
|
151
|
+
hw_type=data[1],
|
|
152
|
+
hw_subtype=data[2],
|
|
153
|
+
hw_major=data[3],
|
|
154
|
+
hw_minor=data[4],
|
|
155
|
+
hw_storage=data[5],
|
|
156
|
+
hw_protocol=data[6],
|
|
157
|
+
sw_vendor=data[7],
|
|
158
|
+
sw_type=data[8],
|
|
159
|
+
sw_subtype=data[9],
|
|
160
|
+
sw_major=data[10],
|
|
161
|
+
sw_minor=data[11],
|
|
162
|
+
sw_storage=data[12],
|
|
163
|
+
sw_protocol=data[13],
|
|
164
|
+
uid=data[14:21],
|
|
165
|
+
batch=data[21:26],
|
|
166
|
+
cw_production=data[26],
|
|
167
|
+
year_production=data[27],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_application_ids(data: bytes) -> list[bytes]:
|
|
172
|
+
return [data[i : i + 3] for i in range(0, len(data) - 2, 3)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DesfireTransport:
|
|
176
|
+
"""Sends DESFire native commands and reassembles 0x91AF multi-frame responses."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, session: CardSession) -> None:
|
|
179
|
+
self.session = session
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _frame(cmd: int, data: bytes = b"") -> bytes:
|
|
183
|
+
if len(data) > 0xFF:
|
|
184
|
+
raise DesfireFrameTooLargeError(
|
|
185
|
+
f"DESFire native frame data is {len(data)} bytes; this transport sends a single "
|
|
186
|
+
"short frame (Lc <= 255, including any command header and MAC). Chunked / "
|
|
187
|
+
"multi-frame (0x91AF) writes are not implemented yet."
|
|
188
|
+
)
|
|
189
|
+
if data:
|
|
190
|
+
return bytes([0x90, cmd, 0x00, 0x00, len(data)]) + bytes(data) + b"\x00"
|
|
191
|
+
return bytes([0x90, cmd, 0x00, 0x00, 0x00])
|
|
192
|
+
|
|
193
|
+
def raw_command(
|
|
194
|
+
self, cmd: int, data: bytes = b"", *, context: str | None = None
|
|
195
|
+
) -> tuple[int, bytes]:
|
|
196
|
+
"""Send one native frame; return ``(status, data)`` without following AF."""
|
|
197
|
+
ctx = context or f"DESFire {cmd:02X}"
|
|
198
|
+
resp = self.session.transmit(self._frame(cmd, data), context=ctx)
|
|
199
|
+
if resp.sw1 != 0x91:
|
|
200
|
+
raise DesfireNotSelectedError(
|
|
201
|
+
f"{ctx}: card answered SW={resp.sw_hex()} (not a DESFire response). "
|
|
202
|
+
"Ensure MIFARE is the selected (default) applet - re-tap the card with no "
|
|
203
|
+
"JavaCard applet selected, on a DESFire-capable contactless reader."
|
|
204
|
+
)
|
|
205
|
+
return resp.sw2, resp.data
|
|
206
|
+
|
|
207
|
+
def command(self, cmd: int, data: bytes = b"", *, context: str | None = None) -> bytes:
|
|
208
|
+
"""Send a native command and return the accumulated response data (no status)."""
|
|
209
|
+
ctx = context or f"DESFire {cmd:02X}"
|
|
210
|
+
status, first = self.raw_command(cmd, data, context=ctx)
|
|
211
|
+
acc = bytearray(first)
|
|
212
|
+
while status == STATUS_ADDITIONAL_FRAME:
|
|
213
|
+
status, more = self.raw_command(CMD_ADDITIONAL_FRAME, context=f"{ctx} (AF)")
|
|
214
|
+
acc += more
|
|
215
|
+
if status != STATUS_OK:
|
|
216
|
+
raise DesfireError(status, context)
|
|
217
|
+
return bytes(acc)
|
|
218
|
+
|
|
219
|
+
# -- read-only operations ---------------------------------------------- #
|
|
220
|
+
def get_version(self) -> DesfireVersion:
|
|
221
|
+
return parse_version(self.command(CMD_GET_VERSION, context="GetVersion"))
|
|
222
|
+
|
|
223
|
+
def get_free_memory(self) -> int:
|
|
224
|
+
data = self.command(CMD_GET_FREE_MEMORY, context="GetFreeMemory")
|
|
225
|
+
return int.from_bytes(data[:3], "little")
|
|
226
|
+
|
|
227
|
+
def application_ids(self) -> list[bytes]:
|
|
228
|
+
return parse_application_ids(
|
|
229
|
+
self.command(CMD_GET_APPLICATION_IDS, context="GetApplicationIDs")
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def select_application(self, aid: bytes) -> None:
|
|
233
|
+
if len(aid) != 3:
|
|
234
|
+
raise ValueError("DESFire AID must be 3 bytes")
|
|
235
|
+
self.command(CMD_SELECT_APPLICATION, bytes(aid), context="SelectApplication")
|
|
236
|
+
|
|
237
|
+
def file_ids(self) -> list[int]:
|
|
238
|
+
return list(self.command(CMD_GET_FILE_IDS, context="GetFileIDs"))
|
|
239
|
+
|
|
240
|
+
def get_key_settings(self) -> tuple[int, int, int]:
|
|
241
|
+
"""GetKeySettings for the selected application: (settings, key_type_bits, max_keys).
|
|
242
|
+
|
|
243
|
+
``key_type_bits`` is the top two bits of the second byte: 0x00 = DES/2K3DES,
|
|
244
|
+
0x40 = 3K3DES, 0x80 = AES. Readable without authentication when the settings
|
|
245
|
+
permit (the factory default does)."""
|
|
246
|
+
data = self.command(CMD_GET_KEY_SETTINGS, context="GetKeySettings")
|
|
247
|
+
if len(data) < 2:
|
|
248
|
+
raise DesfireError(STATUS_OK, "GetKeySettings (short response)")
|
|
249
|
+
return data[0], data[1] & 0xC0, data[1] & 0x0F
|
|
250
|
+
|
|
251
|
+
# -- write operations (plain comm; MACed variants live in ev2.py) ------- #
|
|
252
|
+
def create_application(
|
|
253
|
+
self, aid: bytes, *, key_settings: int = 0x0F, num_keys: int = 3, aes: bool = True
|
|
254
|
+
) -> None:
|
|
255
|
+
"""CreateApplication. KeySettings2 high bits select AES (0x80)."""
|
|
256
|
+
if len(aid) != 3:
|
|
257
|
+
raise ValueError("DESFire AID must be 3 bytes")
|
|
258
|
+
if not (1 <= num_keys <= 14):
|
|
259
|
+
raise ValueError("number of keys must be 1..14")
|
|
260
|
+
ks2 = (0x80 if aes else 0x00) | num_keys
|
|
261
|
+
self.command(
|
|
262
|
+
CMD_CREATE_APPLICATION,
|
|
263
|
+
bytes(aid) + bytes([key_settings, ks2]),
|
|
264
|
+
context="CreateApplication",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def delete_application(self, aid: bytes) -> None:
|
|
268
|
+
if len(aid) != 3:
|
|
269
|
+
raise ValueError("DESFire AID must be 3 bytes")
|
|
270
|
+
self.command(CMD_DELETE_APPLICATION, bytes(aid), context="DeleteApplication")
|
|
271
|
+
|
|
272
|
+
def create_std_data_file(
|
|
273
|
+
self, file_no: int, size: int, *, comm: int = 0x01, access: int = 0xE000, sdm: bool = False
|
|
274
|
+
) -> None:
|
|
275
|
+
"""CreateStdDataFile. ``access`` packs Read|Write|ReadWrite|Change nibbles
|
|
276
|
+
(MSB first); 0xE = free, 0xF = never. Default: free read, key-0 write.
|
|
277
|
+
|
|
278
|
+
``sdm=True`` sets the FileOption SDM bit (0x40) so the file can later carry a
|
|
279
|
+
Secure Dynamic Messaging config (EV3; SDM must be enabled at creation). The SDM
|
|
280
|
+
details themselves are applied with ChangeFileSettings - see ev2.change_file_settings."""
|
|
281
|
+
file_option = (comm & 0x03) | (0x40 if sdm else 0x00)
|
|
282
|
+
data = (
|
|
283
|
+
bytes([file_no, file_option])
|
|
284
|
+
+ access.to_bytes(2, "little")
|
|
285
|
+
+ size.to_bytes(3, "little")
|
|
286
|
+
)
|
|
287
|
+
self.command(CMD_CREATE_STD_DATA_FILE, data, context="CreateStdDataFile")
|
|
288
|
+
|
|
289
|
+
def create_value_file(
|
|
290
|
+
self,
|
|
291
|
+
file_no: int,
|
|
292
|
+
lower: int,
|
|
293
|
+
upper: int,
|
|
294
|
+
value: int,
|
|
295
|
+
*,
|
|
296
|
+
comm: int = 0x01,
|
|
297
|
+
access: int = 0x0000,
|
|
298
|
+
limited_credit: int = 0x00,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""CreateValueFile (plain, like CreateStdDataFile). Default access = key-0 for
|
|
301
|
+
all ops; comm 0x01 = MAC so get/credit/debit go through the EV2 MAC channel."""
|
|
302
|
+
data = (
|
|
303
|
+
bytes([file_no, comm])
|
|
304
|
+
+ access.to_bytes(2, "little")
|
|
305
|
+
+ lower.to_bytes(4, "little", signed=True)
|
|
306
|
+
+ upper.to_bytes(4, "little", signed=True)
|
|
307
|
+
+ value.to_bytes(4, "little", signed=True)
|
|
308
|
+
+ bytes([limited_credit])
|
|
309
|
+
)
|
|
310
|
+
self.command(CMD_CREATE_VALUE_FILE, data, context="CreateValueFile")
|
|
311
|
+
|
|
312
|
+
def create_record_file(
|
|
313
|
+
self,
|
|
314
|
+
file_no: int,
|
|
315
|
+
record_size: int,
|
|
316
|
+
max_records: int,
|
|
317
|
+
*,
|
|
318
|
+
cyclic: bool = False,
|
|
319
|
+
comm: int = 0x01,
|
|
320
|
+
access: int = 0x0000,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""CreateLinear/CyclicRecordFile (plain). comm 0x01 = MAC for write/read."""
|
|
323
|
+
cmd = CMD_CREATE_CYCLIC_RECORD_FILE if cyclic else CMD_CREATE_LINEAR_RECORD_FILE
|
|
324
|
+
data = (
|
|
325
|
+
bytes([file_no, comm])
|
|
326
|
+
+ access.to_bytes(2, "little")
|
|
327
|
+
+ record_size.to_bytes(3, "little")
|
|
328
|
+
+ max_records.to_bytes(3, "little")
|
|
329
|
+
)
|
|
330
|
+
ctx = "CreateCyclicRecordFile" if cyclic else "CreateLinearRecordFile"
|
|
331
|
+
self.command(cmd, data, context=ctx)
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def value_arg(file_no: int, amount: int) -> bytes:
|
|
335
|
+
"""Credit/Debit argument: file number + signed 4-byte little-endian amount."""
|
|
336
|
+
return bytes([file_no]) + amount.to_bytes(4, "little", signed=True)
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def data_header(file_no: int, offset: int, length: int) -> bytes:
|
|
340
|
+
return bytes([file_no]) + offset.to_bytes(3, "little") + length.to_bytes(3, "little")
|
|
341
|
+
|
|
342
|
+
def read_data_plain(self, file_no: int, offset: int = 0, length: int = 0) -> bytes:
|
|
343
|
+
"""ReadData in plain communication (free-read files; length 0 = whole file)."""
|
|
344
|
+
return self.command(
|
|
345
|
+
CMD_READ_DATA, self.data_header(file_no, offset, length), context="ReadData"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def parse_value(data: bytes) -> int:
|
|
350
|
+
"""A GetValue response payload: a signed 4-byte little-endian integer."""
|
|
351
|
+
if len(data) < 4:
|
|
352
|
+
raise ValueError(f"GetValue response too short ({len(data)} bytes)")
|
|
353
|
+
return int.from_bytes(data[:4], "little", signed=True)
|