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,28 @@
|
|
|
1
|
+
"""PC/SC transport: APDU framing, reader selection, status-word decoding."""
|
|
2
|
+
|
|
3
|
+
from cryptnox_id_cli.transport.apdu import APDU, Response
|
|
4
|
+
from cryptnox_id_cli.transport.errors import (
|
|
5
|
+
AppletNotFoundError,
|
|
6
|
+
CardAccessDeniedError,
|
|
7
|
+
CryptnoxError,
|
|
8
|
+
NoCardError,
|
|
9
|
+
NoReadersError,
|
|
10
|
+
ReaderNotFoundError,
|
|
11
|
+
StatusWordError,
|
|
12
|
+
TransportError,
|
|
13
|
+
describe_sw,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"APDU",
|
|
18
|
+
"Response",
|
|
19
|
+
"AppletNotFoundError",
|
|
20
|
+
"CardAccessDeniedError",
|
|
21
|
+
"CryptnoxError",
|
|
22
|
+
"NoCardError",
|
|
23
|
+
"NoReadersError",
|
|
24
|
+
"ReaderNotFoundError",
|
|
25
|
+
"StatusWordError",
|
|
26
|
+
"TransportError",
|
|
27
|
+
"describe_sw",
|
|
28
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""APDU command encoding and response wrapping (ISO 7816-4, short + extended)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class APDU:
|
|
10
|
+
"""A command APDU. ``le`` is the expected response length; use 256 for the
|
|
11
|
+
short-form ``0x00`` ("up to 256 bytes")."""
|
|
12
|
+
|
|
13
|
+
cla: int
|
|
14
|
+
ins: int
|
|
15
|
+
p1: int
|
|
16
|
+
p2: int
|
|
17
|
+
data: bytes = b""
|
|
18
|
+
le: int | None = None
|
|
19
|
+
|
|
20
|
+
def to_bytes(self) -> bytes:
|
|
21
|
+
header = bytes((self.cla & 0xFF, self.ins & 0xFF, self.p1 & 0xFF, self.p2 & 0xFF))
|
|
22
|
+
data = bytes(self.data)
|
|
23
|
+
le = self.le
|
|
24
|
+
extended = len(data) > 0xFF or (le is not None and le > 256)
|
|
25
|
+
|
|
26
|
+
if not data:
|
|
27
|
+
if le is None: # case 1
|
|
28
|
+
return header
|
|
29
|
+
# case 2 (Le only)
|
|
30
|
+
if not extended:
|
|
31
|
+
return header + bytes((le & 0xFF if le != 256 else 0x00,))
|
|
32
|
+
return header + b"\x00" + (le & 0xFFFF if le != 65536 else 0).to_bytes(2, "big")
|
|
33
|
+
|
|
34
|
+
if not extended: # case 3 / 4 short
|
|
35
|
+
out = header + bytes((len(data),)) + data
|
|
36
|
+
if le is not None:
|
|
37
|
+
out += bytes((le & 0xFF if le != 256 else 0x00,))
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
out = header + b"\x00" + len(data).to_bytes(2, "big") + data # case 3 / 4 extended
|
|
41
|
+
if le is not None:
|
|
42
|
+
out += (le & 0xFFFF if le != 65536 else 0).to_bytes(2, "big")
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
def to_list(self) -> list[int]:
|
|
46
|
+
return list(self.to_bytes())
|
|
47
|
+
|
|
48
|
+
def header_hex(self) -> str:
|
|
49
|
+
return bytes((self.cla, self.ins, self.p1, self.p2)).hex().upper()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Response:
|
|
54
|
+
"""A response APDU: payload plus the two status bytes."""
|
|
55
|
+
|
|
56
|
+
data: bytes
|
|
57
|
+
sw1: int
|
|
58
|
+
sw2: int
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def sw(self) -> int:
|
|
62
|
+
return ((self.sw1 & 0xFF) << 8) | (self.sw2 & 0xFF)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def ok(self) -> bool:
|
|
66
|
+
return self.sw == 0x9000
|
|
67
|
+
|
|
68
|
+
def sw_hex(self) -> str:
|
|
69
|
+
return f"{self.sw1:02X}{self.sw2:02X}"
|
|
70
|
+
|
|
71
|
+
def data_hex(self) -> str:
|
|
72
|
+
return self.data.hex().upper()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Windows elevation detection and the FIDO/SCARD_E_NO_ACCESS message.
|
|
2
|
+
|
|
3
|
+
On Windows, selecting the FIDO CTAP AID from a non-elevated process is refused by
|
|
4
|
+
the resource manager with ``SCARD_E_NO_ACCESS`` (0x80100027). We detect this so the
|
|
5
|
+
``fido``/``doctor`` commands can print actionable guidance instead of a raw error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
SCARD_E_NO_ACCESS = 0x80100027
|
|
14
|
+
|
|
15
|
+
# The reason FIDO2 needs elevation - surfaced to the user, not just the bare rule.
|
|
16
|
+
FIDO_REQUIREMENT = (
|
|
17
|
+
"FIDO2 needs an Administrator terminal on Windows because the OS reserves direct "
|
|
18
|
+
"PC/SC access to the FIDO2/CTAP applet for the WebAuthn platform API and blocks "
|
|
19
|
+
"non-elevated processes (SCARD_E_NO_ACCESS)."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
FIDO_WINDOWS_MESSAGE = (
|
|
23
|
+
"FIDO2 access was blocked by Windows.\n"
|
|
24
|
+
"Why: the OS reserves direct PC/SC access to the FIDO2/CTAP AID for the WebAuthn "
|
|
25
|
+
"platform API and denies non-elevated processes (SCARD_E_NO_ACCESS 0x80100027).\n"
|
|
26
|
+
"Fix: run this command from an Administrator terminal - the CLI can relaunch itself "
|
|
27
|
+
"elevated when you confirm - or use Android/NFC tooling."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_windows() -> bool:
|
|
32
|
+
return sys.platform.startswith("win")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_elevated() -> bool | None:
|
|
36
|
+
"""True/False if determinable, else None (unknown platform/error)."""
|
|
37
|
+
if is_windows():
|
|
38
|
+
try:
|
|
39
|
+
import ctypes
|
|
40
|
+
|
|
41
|
+
shell32 = getattr(ctypes, "windll").shell32 # noqa: B009 (windll is Windows-only)
|
|
42
|
+
return bool(shell32.IsUserAnAdmin())
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
geteuid = getattr(os, "geteuid", None) # POSIX-only
|
|
49
|
+
return geteuid() == 0 if geteuid is not None else None
|
|
50
|
+
except OSError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fido_elevation_status() -> tuple[str, str]:
|
|
55
|
+
"""Describe the FIDO elevation requirement for the *current* process.
|
|
56
|
+
|
|
57
|
+
Returns ``(severity, message)`` where severity is ``"ok"`` (requirement met or
|
|
58
|
+
not applicable), ``"warn"`` (requirement not met - action needed), or ``"note"``
|
|
59
|
+
(requirement applies but elevation could not be confirmed). An empty message
|
|
60
|
+
means there is nothing to show (non-Windows).
|
|
61
|
+
"""
|
|
62
|
+
if not is_windows():
|
|
63
|
+
return ("ok", "")
|
|
64
|
+
elev = is_elevated()
|
|
65
|
+
if elev is True:
|
|
66
|
+
return ("ok", f"{FIDO_REQUIREMENT} This terminal is elevated.")
|
|
67
|
+
if elev is False:
|
|
68
|
+
return (
|
|
69
|
+
"warn",
|
|
70
|
+
f"{FIDO_REQUIREMENT} This process is NOT elevated, so the call will fail; "
|
|
71
|
+
"re-run from an Administrator terminal.",
|
|
72
|
+
)
|
|
73
|
+
return ("note", f"{FIDO_REQUIREMENT} Could not confirm this process is elevated.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def relaunch_command() -> tuple[str, list[str], list[str]]:
|
|
77
|
+
"""The (program, prefix, user_args) needed to re-invoke this exact CLI command.
|
|
78
|
+
|
|
79
|
+
``prefix`` is any interpreter prefix (``-m cryptnox_id_cli``) and ``user_args``
|
|
80
|
+
is the original ``sys.argv`` tail. Splitting them lets the caller inject *root*
|
|
81
|
+
options (which click requires BEFORE the subcommand) in the right position.
|
|
82
|
+
Handles both the PyInstaller one-file build (``sys.frozen``) and a module run.
|
|
83
|
+
"""
|
|
84
|
+
if getattr(sys, "frozen", False):
|
|
85
|
+
return sys.executable, [], list(sys.argv[1:])
|
|
86
|
+
return sys.executable, ["-m", "cryptnox_id_cli"], list(sys.argv[1:])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Distinguishes "user clicked No on the UAC prompt" from a real failure.
|
|
90
|
+
ERROR_CANCELLED = 1223
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def relaunch_elevated(extra_args: list[str]) -> int | None:
|
|
94
|
+
"""Re-launch the current command elevated via a Windows UAC prompt and wait.
|
|
95
|
+
|
|
96
|
+
``extra_args`` are root-level options injected BEFORE the subcommand (e.g. a
|
|
97
|
+
result-capture flag). Returns the elevated child's exit code, ``None`` if
|
|
98
|
+
elevation could not be started (non-Windows, the user declined UAC, or an error).
|
|
99
|
+
"""
|
|
100
|
+
if not is_windows():
|
|
101
|
+
return None
|
|
102
|
+
import ctypes
|
|
103
|
+
import ctypes.wintypes as wintypes
|
|
104
|
+
|
|
105
|
+
program, prefix, user_args = relaunch_command()
|
|
106
|
+
params = subprocess.list2cmdline([*prefix, *extra_args, *user_args])
|
|
107
|
+
|
|
108
|
+
class _SHELLEXECUTEINFOW(ctypes.Structure):
|
|
109
|
+
_fields_ = [
|
|
110
|
+
("cbSize", wintypes.DWORD),
|
|
111
|
+
("fMask", ctypes.c_ulong),
|
|
112
|
+
("hwnd", wintypes.HWND),
|
|
113
|
+
("lpVerb", wintypes.LPCWSTR),
|
|
114
|
+
("lpFile", wintypes.LPCWSTR),
|
|
115
|
+
("lpParameters", wintypes.LPCWSTR),
|
|
116
|
+
("lpDirectory", wintypes.LPCWSTR),
|
|
117
|
+
("nShow", ctypes.c_int),
|
|
118
|
+
("hInstApp", wintypes.HINSTANCE),
|
|
119
|
+
("lpIDList", ctypes.c_void_p),
|
|
120
|
+
("lpClass", wintypes.LPCWSTR),
|
|
121
|
+
("hkeyClass", wintypes.HKEY),
|
|
122
|
+
("dwHotKey", wintypes.DWORD),
|
|
123
|
+
("hIcon", wintypes.HANDLE),
|
|
124
|
+
("hProcess", wintypes.HANDLE),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
see_mask_nocloseprocess = 0x00000040
|
|
128
|
+
sw_hide = 0
|
|
129
|
+
infinite = 0xFFFFFFFF
|
|
130
|
+
|
|
131
|
+
shell32 = getattr(ctypes, "windll").shell32 # noqa: B009 (windll is Windows-only)
|
|
132
|
+
kernel32 = getattr(ctypes, "windll").kernel32 # noqa: B009
|
|
133
|
+
|
|
134
|
+
sei = _SHELLEXECUTEINFOW()
|
|
135
|
+
sei.cbSize = ctypes.sizeof(sei)
|
|
136
|
+
sei.fMask = see_mask_nocloseprocess
|
|
137
|
+
sei.lpVerb = "runas"
|
|
138
|
+
sei.lpFile = program
|
|
139
|
+
sei.lpParameters = params
|
|
140
|
+
sei.nShow = sw_hide
|
|
141
|
+
|
|
142
|
+
if not shell32.ShellExecuteExW(ctypes.byref(sei)) or not sei.hProcess:
|
|
143
|
+
return None # declined UAC (GetLastError == ERROR_CANCELLED) or failed to start
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
kernel32.WaitForSingleObject(sei.hProcess, infinite)
|
|
147
|
+
code = wintypes.DWORD()
|
|
148
|
+
kernel32.GetExitCodeProcess(sei.hProcess, ctypes.byref(code))
|
|
149
|
+
return int(code.value)
|
|
150
|
+
finally:
|
|
151
|
+
kernel32.CloseHandle(sei.hProcess)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def looks_like_no_access(exc: BaseException) -> bool:
|
|
155
|
+
"""Heuristically detect the Windows no-access block from a pyscard exception."""
|
|
156
|
+
hr = getattr(exc, "hresult", None)
|
|
157
|
+
if isinstance(hr, int) and (hr & 0xFFFFFFFF) == SCARD_E_NO_ACCESS:
|
|
158
|
+
return True
|
|
159
|
+
s = str(exc).lower()
|
|
160
|
+
return (
|
|
161
|
+
"0x80100027" in s
|
|
162
|
+
or "scard_e_no_access" in s
|
|
163
|
+
or "access is denied" in s
|
|
164
|
+
or "access denied" in s
|
|
165
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Exception hierarchy and friendly ISO 7816 status-word decoding.
|
|
2
|
+
|
|
3
|
+
Every status word is mapped to a plain-language message plus the raw code, so the
|
|
4
|
+
CLI never shows a bare ``SW=6982``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CryptnoxError(Exception):
|
|
13
|
+
"""Base class for all errors surfaced by the CLI's error funnel."""
|
|
14
|
+
|
|
15
|
+
exit_code = 1
|
|
16
|
+
#: short machine token, e.g. "card_access_denied"
|
|
17
|
+
code = "error"
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, object]:
|
|
20
|
+
return {"error": self.code, "message": str(self)}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TransportError(CryptnoxError):
|
|
24
|
+
code = "transport_error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NoReadersError(TransportError):
|
|
28
|
+
code = "no_readers"
|
|
29
|
+
exit_code = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReaderNotFoundError(TransportError):
|
|
33
|
+
code = "reader_not_found"
|
|
34
|
+
exit_code = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoCardError(TransportError):
|
|
38
|
+
code = "no_card"
|
|
39
|
+
exit_code = 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CardAccessDeniedError(TransportError):
|
|
43
|
+
"""PC/SC refused the transmit (e.g. Windows blocking the FIDO CTAP AID)."""
|
|
44
|
+
|
|
45
|
+
code = "card_access_denied"
|
|
46
|
+
exit_code = 4
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, *, hresult: int | None = None) -> None:
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.hresult = hresult
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AppletNotFoundError(CryptnoxError):
|
|
54
|
+
code = "applet_not_found"
|
|
55
|
+
exit_code = 5
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Scp03Error(CryptnoxError):
|
|
59
|
+
"""SCP03 secure-channel failure (wrong keys, bad cryptogram, MAC error)."""
|
|
60
|
+
|
|
61
|
+
code = "scp03_error"
|
|
62
|
+
exit_code = 7
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Scp02Error(CryptnoxError):
|
|
66
|
+
"""SCP02 secure-channel failure (wrong keys, bad cryptogram, MAC error)."""
|
|
67
|
+
|
|
68
|
+
code = "scp02_error"
|
|
69
|
+
exit_code = 7
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class SWInfo:
|
|
74
|
+
sw: int
|
|
75
|
+
name: str
|
|
76
|
+
message: str
|
|
77
|
+
ok: bool = False
|
|
78
|
+
more_data: int | None = None # 61xx -> bytes still available
|
|
79
|
+
wrong_le: int | None = None # 6Cxx -> correct Le
|
|
80
|
+
retries: int | None = None # 63Cx -> remaining tries
|
|
81
|
+
|
|
82
|
+
def sw_hex(self) -> str:
|
|
83
|
+
return f"{self.sw:04X}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Exact status words → (name, friendly message).
|
|
87
|
+
_SW_TABLE: dict[int, tuple[str, str]] = {
|
|
88
|
+
0x9000: ("OK", "Success."),
|
|
89
|
+
0x6982: (
|
|
90
|
+
"SECURITY_STATUS_NOT_SATISFIED",
|
|
91
|
+
"Security status not satisfied. You probably need to verify the PIN or "
|
|
92
|
+
"authenticate with the admin key before running this command.",
|
|
93
|
+
),
|
|
94
|
+
0x6983: (
|
|
95
|
+
"AUTH_METHOD_BLOCKED",
|
|
96
|
+
"Authentication method blocked. The PIN or PUK is likely blocked.",
|
|
97
|
+
),
|
|
98
|
+
0x6985: (
|
|
99
|
+
"CONDITIONS_NOT_SATISFIED",
|
|
100
|
+
"Conditions of use not satisfied. The applet may be in the wrong lifecycle state "
|
|
101
|
+
"for this command.",
|
|
102
|
+
),
|
|
103
|
+
0x6A80: (
|
|
104
|
+
"WRONG_DATA",
|
|
105
|
+
"Incorrect data. Check the command parameters or object format.",
|
|
106
|
+
),
|
|
107
|
+
0x6A81: ("FUNC_NOT_SUPPORTED", "Function not supported by this applet."),
|
|
108
|
+
0x6A82: (
|
|
109
|
+
"FILE_NOT_FOUND",
|
|
110
|
+
"File, object or applet not found. The selected item may not exist on this card.",
|
|
111
|
+
),
|
|
112
|
+
0x6A84: ("NOT_ENOUGH_MEMORY", "Not enough memory on the card for this operation."),
|
|
113
|
+
0x6A86: ("WRONG_P1P2", "Incorrect P1/P2 parameters."),
|
|
114
|
+
0x6A88: (
|
|
115
|
+
"REFERENCE_DATA_NOT_FOUND",
|
|
116
|
+
"Referenced data not found (e.g. the PIN/key reference is not configured).",
|
|
117
|
+
),
|
|
118
|
+
0x6700: ("WRONG_LENGTH", "Wrong length (Lc/Le)."),
|
|
119
|
+
0x6D00: ("INS_NOT_SUPPORTED", "Instruction (INS) not supported by this applet."),
|
|
120
|
+
0x6E00: (
|
|
121
|
+
"CLA_NOT_SUPPORTED",
|
|
122
|
+
"Class (CLA) not supported; this function is not reachable over this interface "
|
|
123
|
+
"(e.g. a contactless-only function on a contact reader).",
|
|
124
|
+
),
|
|
125
|
+
0x6881: ("LOGICAL_CHANNEL_UNSUPPORTED", "Logical channel not supported."),
|
|
126
|
+
0x6882: ("SM_UNSUPPORTED", "Secure messaging not supported."),
|
|
127
|
+
0x6999: ("APPLET_SELECT_FAILED", "Applet selection failed or refused."),
|
|
128
|
+
0x6F00: ("NO_PRECISE_DIAGNOSIS", "Unknown card error (no precise diagnosis)."),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def describe_sw(sw1: int, sw2: int) -> SWInfo:
|
|
133
|
+
"""Decode a status word into a friendly :class:`SWInfo`."""
|
|
134
|
+
sw = ((sw1 & 0xFF) << 8) | (sw2 & 0xFF)
|
|
135
|
+
if sw == 0x9000:
|
|
136
|
+
return SWInfo(sw, "OK", "Success.", ok=True)
|
|
137
|
+
if sw1 == 0x61:
|
|
138
|
+
return SWInfo(
|
|
139
|
+
sw, "MORE_DATA", f"{sw2} more byte(s) available (GET RESPONSE).", more_data=sw2
|
|
140
|
+
)
|
|
141
|
+
if sw1 == 0x6C:
|
|
142
|
+
return SWInfo(sw, "WRONG_LE", f"Wrong Le; resend with Le=0x{sw2:02X}.", wrong_le=sw2)
|
|
143
|
+
if sw1 == 0x63 and (sw2 & 0xF0) == 0xC0:
|
|
144
|
+
tries = sw2 & 0x0F
|
|
145
|
+
return SWInfo(
|
|
146
|
+
sw,
|
|
147
|
+
"VERIFY_FAILED",
|
|
148
|
+
f"Verification failed; {tries} attempt(s) remaining.",
|
|
149
|
+
retries=tries,
|
|
150
|
+
)
|
|
151
|
+
if sw == 0x6300:
|
|
152
|
+
return SWInfo(sw, "VERIFY_FAILED", "Verification failed.")
|
|
153
|
+
if sw in _SW_TABLE and _SW_TABLE[sw][1]:
|
|
154
|
+
name, msg = _SW_TABLE[sw]
|
|
155
|
+
return SWInfo(sw, name, msg)
|
|
156
|
+
# Family fallbacks.
|
|
157
|
+
if sw1 == 0x63:
|
|
158
|
+
return SWInfo(sw, "WARNING", "Operation completed with warning / counter changed.")
|
|
159
|
+
if sw1 in (0x62, 0x63):
|
|
160
|
+
return SWInfo(sw, "WARNING", f"Warning (SW={sw:04X}).")
|
|
161
|
+
if sw1 in (0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F):
|
|
162
|
+
return SWInfo(sw, "ERROR", f"Card returned error SW={sw:04X}.")
|
|
163
|
+
return SWInfo(sw, "UNKNOWN", f"Unrecognised status word SW={sw:04X}.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class StatusWordError(CryptnoxError):
|
|
167
|
+
"""A command returned a non-success status word."""
|
|
168
|
+
|
|
169
|
+
code = "status_word"
|
|
170
|
+
exit_code = 6
|
|
171
|
+
|
|
172
|
+
def __init__(self, sw1: int, sw2: int, *, context: str | None = None) -> None:
|
|
173
|
+
self.info = describe_sw(sw1, sw2)
|
|
174
|
+
self.context = context
|
|
175
|
+
prefix = f"{context}: " if context else ""
|
|
176
|
+
super().__init__(f"{prefix}{self.info.message} (SW={self.info.sw_hex()})")
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict[str, object]:
|
|
179
|
+
return {
|
|
180
|
+
"error": self.code,
|
|
181
|
+
"message": str(self),
|
|
182
|
+
"sw": self.info.sw_hex(),
|
|
183
|
+
"sw_name": self.info.name,
|
|
184
|
+
"context": self.context,
|
|
185
|
+
}
|