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,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
+ }