jkctl 0.1.0__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.
- jkctl/__init__.py +64 -0
- jkctl/__main__.py +5 -0
- jkctl/aes.py +359 -0
- jkctl/cli/__init__.py +58 -0
- jkctl/cli/commands/__init__.py +51 -0
- jkctl/cli/commands/config.py +100 -0
- jkctl/cli/commands/controls.py +278 -0
- jkctl/cli/commands/devices.py +211 -0
- jkctl/cli/commands/diag.py +136 -0
- jkctl/cli/commands/firmware.py +357 -0
- jkctl/cli/commands/history.py +134 -0
- jkctl/cli/commands/protocols.py +264 -0
- jkctl/cli/commands/registers.py +171 -0
- jkctl/cli/commands/settings.py +274 -0
- jkctl/cli/commands/status.py +338 -0
- jkctl/cli/commands/ui.py +240 -0
- jkctl/cli/exits.py +38 -0
- jkctl/cli/fanout.py +57 -0
- jkctl/cli/main.py +68 -0
- jkctl/cli/parser.py +136 -0
- jkctl/cli/report.py +66 -0
- jkctl/cli/target.py +191 -0
- jkctl/config.py +210 -0
- jkctl/controls.py +201 -0
- jkctl/device.py +439 -0
- jkctl/doctor.py +366 -0
- jkctl/errors.py +32 -0
- jkctl/firmware.py +363 -0
- jkctl/history.py +219 -0
- jkctl/identity.py +161 -0
- jkctl/logcodes.json +92 -0
- jkctl/logcodes.py +74 -0
- jkctl/modbus.py +630 -0
- jkctl/names.py +300 -0
- jkctl/probe.py +457 -0
- jkctl/protocol.py +353 -0
- jkctl/protocol_en.json +1 -0
- jkctl/protocol_zh.json +1 -0
- jkctl/protocols.json +299 -0
- jkctl/registers.py +553 -0
- jkctl/runtime.py +278 -0
- jkctl/settings.py +303 -0
- jkctl/simulator.py +557 -0
- jkctl/tracing.py +200 -0
- jkctl/upgrade.py +149 -0
- jkctl/values.py +293 -0
- jkctl/web/__init__.py +27 -0
- jkctl/web/api.py +992 -0
- jkctl/web/schema.py +408 -0
- jkctl/web/server.py +112 -0
- jkctl/web/session.py +449 -0
- jkctl/web/static/app.css +423 -0
- jkctl/web/static/index.html +30 -0
- jkctl/web/static/js/api.js +55 -0
- jkctl/web/static/js/app.js +1199 -0
- jkctl/web/static/js/bands.js +380 -0
- jkctl/web/static/js/bank.js +223 -0
- jkctl/web/static/js/chart.js +299 -0
- jkctl/web/static/js/dashboard.js +433 -0
- jkctl/web/static/js/firmware.js +273 -0
- jkctl/web/static/js/history.js +134 -0
- jkctl/web/static/js/panels.js +176 -0
- jkctl/web/static/js/ports.js +145 -0
- jkctl/web/static/js/registers.js +144 -0
- jkctl/web/static/js/settings.js +360 -0
- jkctl/web/static/js/tools.js +510 -0
- jkctl-0.1.0.dist-info/METADATA +278 -0
- jkctl-0.1.0.dist-info/RECORD +72 -0
- jkctl-0.1.0.dist-info/WHEEL +4 -0
- jkctl-0.1.0.dist-info/entry_points.txt +2 -0
- jkctl-0.1.0.dist-info/licenses/LICENSE +287 -0
- jkctl-0.1.0.dist-info/licenses/NOTICE +34 -0
jkctl/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""jkctl -- a cross-platform command-line tool for JK BMS battery management systems.
|
|
2
|
+
|
|
3
|
+
Everything here was derived from JK BMS Monitor 3.11.0 (the vendor's Windows
|
|
4
|
+
application), the encrypted protocol datasource shipped with it, and JK's own
|
|
5
|
+
RS485 Modbus register-map documents. The reverse-engineering record lives in
|
|
6
|
+
``research/windows/windows-findings.md``; ``docs/how-it-works.md`` is the
|
|
7
|
+
readable summary.
|
|
8
|
+
|
|
9
|
+
The package is one module per subject, and none of them prints:
|
|
10
|
+
|
|
11
|
+
* :mod:`jkctl.modbus` -- the Modbus RTU link and the JK register bases;
|
|
12
|
+
* :mod:`jkctl.protocol` -- the frame/field layout, from the datasource;
|
|
13
|
+
* :mod:`jkctl.registers` -- the catalog: which field lives where, and whether
|
|
14
|
+
it may be written;
|
|
15
|
+
* :mod:`jkctl.values` -- turning typed input into wire values and back;
|
|
16
|
+
* :mod:`jkctl.device` -- one BMS on the bus, read and written by field name;
|
|
17
|
+
* :mod:`jkctl.identity` / :mod:`jkctl.runtime` / :mod:`jkctl.settings` --
|
|
18
|
+
a snapshot of each of the three readable tables;
|
|
19
|
+
* :mod:`jkctl.controls` -- the write-only action registers;
|
|
20
|
+
* :mod:`jkctl.firmware` / :mod:`jkctl.upgrade` -- the ``.jkbms`` container and
|
|
21
|
+
the XMODEM transfer that flashes it;
|
|
22
|
+
* :mod:`jkctl.aes` -- AES-256-CBC with no package to install;
|
|
23
|
+
* :mod:`jkctl.probe` -- read-only bus reconnaissance;
|
|
24
|
+
* :mod:`jkctl.simulator` -- a fake BMS on a serial port, for testing;
|
|
25
|
+
* :mod:`jkctl.config` -- the optional TOML settings file.
|
|
26
|
+
|
|
27
|
+
What is not specific to a BMS lives in ``devicectl-core`` and is shared with
|
|
28
|
+
the other programs of this shape: where slow work reports to
|
|
29
|
+
(:mod:`devicectl.report`, :mod:`devicectl.progress`), the subcommand table
|
|
30
|
+
(:mod:`devicectl.cli.command`), the event broadcaster
|
|
31
|
+
(:mod:`devicectl.web.events`) and the HTTP primitives
|
|
32
|
+
(:mod:`devicectl.web.http`).
|
|
33
|
+
|
|
34
|
+
Printing, prompting and exit codes belong to :mod:`jkctl.cli` alone.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
from jkctl.device import Device
|
|
42
|
+
from jkctl.errors import JkError
|
|
43
|
+
from jkctl.firmware import Firmware, FirmwareError
|
|
44
|
+
from jkctl.modbus import Bus, ModbusError
|
|
45
|
+
from jkctl.protocol import Protocol
|
|
46
|
+
from jkctl.registers import Action, Catalog, Register
|
|
47
|
+
from jkctl.upgrade import UpgradeError
|
|
48
|
+
from jkctl.values import RegisterValueError
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Action",
|
|
52
|
+
"Bus",
|
|
53
|
+
"Catalog",
|
|
54
|
+
"Device",
|
|
55
|
+
"Firmware",
|
|
56
|
+
"FirmwareError",
|
|
57
|
+
"JkError",
|
|
58
|
+
"ModbusError",
|
|
59
|
+
"Protocol",
|
|
60
|
+
"Register",
|
|
61
|
+
"RegisterValueError",
|
|
62
|
+
"UpgradeError",
|
|
63
|
+
"__version__",
|
|
64
|
+
]
|
jkctl/__main__.py
ADDED
jkctl/aes.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""AES-256-CBC for the .jkbms / .jsonds containers, with nothing to install.
|
|
2
|
+
|
|
3
|
+
The Windows app uses AES-256-CBC (IV = all zero) over the container payload.
|
|
4
|
+
Decrypting it is the only cryptographic operation the tool performs, and it runs
|
|
5
|
+
exactly once per firmware file, on a blob of at most 20 MiB. So a pure-Python
|
|
6
|
+
implementation is fast enough, and shipping one means jkctl imports and
|
|
7
|
+
runs on any architecture -- notably aarch64 -- without needing pycrypto (which
|
|
8
|
+
has no prebuilt aarch64 wheel and requires a C toolchain to build).
|
|
9
|
+
|
|
10
|
+
``decrypt_cbc`` prefers a fast native backend when one is available and silently
|
|
11
|
+
falls back to the bundled pure-Python cipher otherwise:
|
|
12
|
+
|
|
13
|
+
1. PyCryptodome / PyCrypto (``from Crypto.Cipher import AES``)
|
|
14
|
+
2. cryptography (OpenSSL, via its Python wheel)
|
|
15
|
+
3. the system libcrypto (OpenSSL, via stdlib ctypes -- no install)
|
|
16
|
+
4. the pure-Python AES below
|
|
17
|
+
|
|
18
|
+
Backend 3 is the important one for small/weak/exotic hosts (e.g. a 32-bit
|
|
19
|
+
armv7l Raspberry Pi): OpenSSL's ``libcrypto`` is already present on virtually
|
|
20
|
+
every system, and ctypes is in the standard library, so it gives C-speed AES
|
|
21
|
+
with nothing to install and no compiler -- whereas ``cryptography`` needs Rust
|
|
22
|
+
and ``pycryptodome`` a C toolchain when no wheel exists for the architecture.
|
|
23
|
+
|
|
24
|
+
All backends are byte-for-byte identical; ``backend_name()`` reports which is in
|
|
25
|
+
use, and the self-test at the bottom checks the fallbacks against FIPS-197.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
# Pure-Python AES-256 (decrypt path only -- that is all the tool needs) #
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
|
|
34
|
+
# AES-256 with a 16-byte block: the key is 32 bytes and the schedule works in
|
|
35
|
+
# four-byte words.
|
|
36
|
+
KEY_BYTES = 32
|
|
37
|
+
BLOCK_BYTES = 16
|
|
38
|
+
WORD = 4
|
|
39
|
+
|
|
40
|
+
_SBOX = bytes.fromhex(
|
|
41
|
+
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
|
|
42
|
+
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
|
|
43
|
+
"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
|
|
44
|
+
"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
|
|
45
|
+
"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
|
|
46
|
+
"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
|
|
47
|
+
"ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
|
|
48
|
+
"e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16"
|
|
49
|
+
)
|
|
50
|
+
_INV_SBOX = bytearray(256)
|
|
51
|
+
for _i, _v in enumerate(_SBOX):
|
|
52
|
+
_INV_SBOX[_v] = _i
|
|
53
|
+
_INV_SBOX = bytes(_INV_SBOX)
|
|
54
|
+
|
|
55
|
+
_RCON = (
|
|
56
|
+
0x01,
|
|
57
|
+
0x02,
|
|
58
|
+
0x04,
|
|
59
|
+
0x08,
|
|
60
|
+
0x10,
|
|
61
|
+
0x20,
|
|
62
|
+
0x40,
|
|
63
|
+
0x80,
|
|
64
|
+
0x1B,
|
|
65
|
+
0x36,
|
|
66
|
+
0x6C,
|
|
67
|
+
0xD8,
|
|
68
|
+
0xAB,
|
|
69
|
+
0x4D,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mul(a: int, b: int) -> int:
|
|
74
|
+
"""Multiply two bytes in GF(2^8) with the AES reduction polynomial."""
|
|
75
|
+
p = 0
|
|
76
|
+
for _ in range(8):
|
|
77
|
+
if b & 1:
|
|
78
|
+
p ^= a
|
|
79
|
+
hi = a & 0x80
|
|
80
|
+
a = (a << 1) & 0xFF
|
|
81
|
+
if hi:
|
|
82
|
+
a ^= 0x1B
|
|
83
|
+
b >>= 1
|
|
84
|
+
return p
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _expand_key(key: bytes) -> list[list[int]]:
|
|
88
|
+
"""AES-256 key schedule -> 60 four-byte words."""
|
|
89
|
+
if len(key) != KEY_BYTES:
|
|
90
|
+
raise ValueError("AES-256 needs a 32-byte key, got %d" % len(key))
|
|
91
|
+
nk, nr = 8, 14
|
|
92
|
+
w = [list(key[WORD * i : WORD * i + WORD]) for i in range(nk)]
|
|
93
|
+
for i in range(nk, WORD * (nr + 1)):
|
|
94
|
+
t = list(w[i - 1])
|
|
95
|
+
if i % nk == 0:
|
|
96
|
+
t = t[1:] + t[:1] # RotWord
|
|
97
|
+
t = [_SBOX[b] for b in t] # SubWord
|
|
98
|
+
t[0] ^= _RCON[i // nk - 1]
|
|
99
|
+
elif i % nk == WORD:
|
|
100
|
+
t = [_SBOX[b] for b in t] # SubWord (AES-256 only)
|
|
101
|
+
w.append([a ^ b for a, b in zip(w[i - nk], t)])
|
|
102
|
+
return w
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# The four inverse-cipher steps, named as FIPS-197 names them. Each works on
|
|
106
|
+
# the 16-byte state in place, column-major, so `s[r + 4 * c]` is row r of
|
|
107
|
+
# column c.
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _inv_shift_rows(s: list[int]) -> None:
|
|
111
|
+
"""Rotate row r right by r bytes."""
|
|
112
|
+
for r in range(1, 4):
|
|
113
|
+
row = [s[r + 4 * c] for c in range(4)]
|
|
114
|
+
row = row[-r:] + row[:-r]
|
|
115
|
+
for c in range(4):
|
|
116
|
+
s[r + 4 * c] = row[c]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _inv_sub_bytes(s: list[int]) -> None:
|
|
120
|
+
"""Substitute every byte through the inverse S-box."""
|
|
121
|
+
for i in range(16):
|
|
122
|
+
s[i] = _INV_SBOX[s[i]]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _inv_mix_columns(s: list[int]) -> None:
|
|
126
|
+
"""Multiply each column by the inverse MDS matrix, over GF(2^8)."""
|
|
127
|
+
for c in range(4):
|
|
128
|
+
a = [s[4 * c + r] for r in range(4)]
|
|
129
|
+
s[4 * c + 0] = _mul(a[0], 14) ^ _mul(a[1], 11) ^ _mul(a[2], 13) ^ _mul(a[3], 9)
|
|
130
|
+
s[4 * c + 1] = _mul(a[0], 9) ^ _mul(a[1], 14) ^ _mul(a[2], 11) ^ _mul(a[3], 13)
|
|
131
|
+
s[4 * c + 2] = _mul(a[0], 13) ^ _mul(a[1], 9) ^ _mul(a[2], 14) ^ _mul(a[3], 11)
|
|
132
|
+
s[4 * c + 3] = _mul(a[0], 11) ^ _mul(a[1], 13) ^ _mul(a[2], 9) ^ _mul(a[3], 14)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _add_round_key(s: list[int], w: list[list[int]], rnd: int) -> None:
|
|
136
|
+
"""XOR in the four round-key words for round ``rnd``."""
|
|
137
|
+
for c in range(4):
|
|
138
|
+
k = w[rnd * 4 + c]
|
|
139
|
+
for r in range(4):
|
|
140
|
+
s[r + 4 * c] ^= k[r]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _decrypt_block(block: bytes, w: list[list[int]]) -> bytes:
|
|
144
|
+
"""Run the AES-256 inverse cipher over one 16-byte block."""
|
|
145
|
+
nr = 14 # rounds, for a 256-bit key
|
|
146
|
+
s = list(block)
|
|
147
|
+
_add_round_key(s, w, nr)
|
|
148
|
+
for rnd in range(nr - 1, 0, -1):
|
|
149
|
+
_inv_shift_rows(s)
|
|
150
|
+
_inv_sub_bytes(s)
|
|
151
|
+
_add_round_key(s, w, rnd)
|
|
152
|
+
_inv_mix_columns(s)
|
|
153
|
+
# The final round is the same without InvMixColumns.
|
|
154
|
+
_inv_shift_rows(s)
|
|
155
|
+
_inv_sub_bytes(s)
|
|
156
|
+
_add_round_key(s, w, 0)
|
|
157
|
+
return bytes(s)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pure_decrypt_cbc(key: bytes, iv: bytes, data: bytes) -> bytes:
|
|
161
|
+
if len(data) % 16:
|
|
162
|
+
raise ValueError("ciphertext not a multiple of 16 bytes")
|
|
163
|
+
w = _expand_key(key)
|
|
164
|
+
out = bytearray()
|
|
165
|
+
prev = iv
|
|
166
|
+
for i in range(0, len(data), 16):
|
|
167
|
+
block = data[i : i + 16]
|
|
168
|
+
clear = _decrypt_block(block, w)
|
|
169
|
+
out += bytes(a ^ b for a, b in zip(clear, prev))
|
|
170
|
+
prev = block
|
|
171
|
+
return bytes(out)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
# Backend selection #
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _try_crypto(key, iv, data):
|
|
180
|
+
# pycryptodome or pycrypto; an optional backend, so the checker cannot
|
|
181
|
+
# be expected to resolve it.
|
|
182
|
+
from Crypto.Cipher import AES # ty: ignore[unresolved-import]
|
|
183
|
+
|
|
184
|
+
return AES.new(key, AES.MODE_CBC, iv).decrypt(data)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _try_cryptography(key, iv, data):
|
|
188
|
+
from cryptography.hazmat.primitives.ciphers import ( # ty: ignore[unresolved-import]
|
|
189
|
+
Cipher,
|
|
190
|
+
algorithms,
|
|
191
|
+
modes,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
|
|
195
|
+
return dec.update(data) + dec.finalize()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# -- system libcrypto via ctypes (no install, no compiler) ------------------- #
|
|
199
|
+
|
|
200
|
+
_LIBCRYPTO = None
|
|
201
|
+
_LIBCRYPTO_TRIED = False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _load_libcrypto():
|
|
205
|
+
"""Locate and load OpenSSL's libcrypto, or return None. Cached."""
|
|
206
|
+
global _LIBCRYPTO, _LIBCRYPTO_TRIED
|
|
207
|
+
if _LIBCRYPTO_TRIED:
|
|
208
|
+
return _LIBCRYPTO
|
|
209
|
+
_LIBCRYPTO_TRIED = True
|
|
210
|
+
import ctypes
|
|
211
|
+
import ctypes.util
|
|
212
|
+
|
|
213
|
+
names = []
|
|
214
|
+
found = ctypes.util.find_library("crypto")
|
|
215
|
+
if found:
|
|
216
|
+
names.append(found)
|
|
217
|
+
names += [
|
|
218
|
+
"libcrypto.so.3",
|
|
219
|
+
"libcrypto.so.1.1",
|
|
220
|
+
"libcrypto.so.1.0.0",
|
|
221
|
+
"libcrypto.so",
|
|
222
|
+
"libcrypto.dylib",
|
|
223
|
+
"libcrypto-3.dll",
|
|
224
|
+
"libcrypto-1_1.dll",
|
|
225
|
+
"libeay32.dll",
|
|
226
|
+
]
|
|
227
|
+
for n in names:
|
|
228
|
+
try:
|
|
229
|
+
lib = ctypes.CDLL(n)
|
|
230
|
+
except OSError:
|
|
231
|
+
continue
|
|
232
|
+
if hasattr(lib, "EVP_DecryptUpdate") and hasattr(lib, "EVP_aes_256_cbc"):
|
|
233
|
+
_LIBCRYPTO = lib
|
|
234
|
+
break
|
|
235
|
+
return _LIBCRYPTO
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _try_openssl(key, iv, data):
|
|
239
|
+
import ctypes as c
|
|
240
|
+
|
|
241
|
+
lib = _load_libcrypto()
|
|
242
|
+
if lib is None:
|
|
243
|
+
raise ImportError("libcrypto not available")
|
|
244
|
+
if len(key) != KEY_BYTES:
|
|
245
|
+
raise ValueError("AES-256 needs a 32-byte key, got %d" % len(key))
|
|
246
|
+
if len(data) % 16:
|
|
247
|
+
raise ValueError("ciphertext not a multiple of 16 bytes")
|
|
248
|
+
|
|
249
|
+
lib.EVP_CIPHER_CTX_new.restype = c.c_void_p
|
|
250
|
+
lib.EVP_aes_256_cbc.restype = c.c_void_p
|
|
251
|
+
lib.EVP_DecryptInit_ex.argtypes = [
|
|
252
|
+
c.c_void_p,
|
|
253
|
+
c.c_void_p,
|
|
254
|
+
c.c_void_p,
|
|
255
|
+
c.c_char_p,
|
|
256
|
+
c.c_char_p,
|
|
257
|
+
]
|
|
258
|
+
lib.EVP_DecryptInit_ex.restype = c.c_int
|
|
259
|
+
lib.EVP_CIPHER_CTX_set_padding.argtypes = [c.c_void_p, c.c_int]
|
|
260
|
+
lib.EVP_CIPHER_CTX_set_padding.restype = c.c_int
|
|
261
|
+
lib.EVP_DecryptUpdate.argtypes = [
|
|
262
|
+
c.c_void_p,
|
|
263
|
+
c.c_char_p,
|
|
264
|
+
c.POINTER(c.c_int),
|
|
265
|
+
c.c_char_p,
|
|
266
|
+
c.c_int,
|
|
267
|
+
]
|
|
268
|
+
lib.EVP_DecryptUpdate.restype = c.c_int
|
|
269
|
+
lib.EVP_DecryptFinal_ex.argtypes = [c.c_void_p, c.c_char_p, c.POINTER(c.c_int)]
|
|
270
|
+
lib.EVP_DecryptFinal_ex.restype = c.c_int
|
|
271
|
+
lib.EVP_CIPHER_CTX_free.argtypes = [c.c_void_p]
|
|
272
|
+
lib.EVP_CIPHER_CTX_free.restype = None
|
|
273
|
+
|
|
274
|
+
ctx = lib.EVP_CIPHER_CTX_new()
|
|
275
|
+
if not ctx:
|
|
276
|
+
raise RuntimeError("EVP_CIPHER_CTX_new failed")
|
|
277
|
+
try:
|
|
278
|
+
if lib.EVP_DecryptInit_ex(ctx, lib.EVP_aes_256_cbc(), None, key, iv) != 1:
|
|
279
|
+
raise RuntimeError("EVP_DecryptInit_ex failed")
|
|
280
|
+
lib.EVP_CIPHER_CTX_set_padding(ctx, 0) # container manages its own length
|
|
281
|
+
out = c.create_string_buffer(len(data) + 16)
|
|
282
|
+
outlen = c.c_int(0)
|
|
283
|
+
if lib.EVP_DecryptUpdate(ctx, out, c.byref(outlen), data, len(data)) != 1:
|
|
284
|
+
raise RuntimeError("EVP_DecryptUpdate failed")
|
|
285
|
+
total = outlen.value
|
|
286
|
+
fin = c.create_string_buffer(16)
|
|
287
|
+
finlen = c.c_int(0)
|
|
288
|
+
if lib.EVP_DecryptFinal_ex(ctx, fin, c.byref(finlen)) != 1:
|
|
289
|
+
raise RuntimeError("EVP_DecryptFinal_ex failed")
|
|
290
|
+
return out.raw[:total] + fin.raw[: finlen.value]
|
|
291
|
+
finally:
|
|
292
|
+
lib.EVP_CIPHER_CTX_free(ctx)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _mod_available(mod: str) -> bool:
|
|
296
|
+
import importlib.util
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
return importlib.util.find_spec(mod) is not None
|
|
300
|
+
except Exception: # noqa: BLE001 - a probe that raises is a backend that is absent
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# (name, availability probe, decrypt fn) in preference order.
|
|
305
|
+
_BACKENDS = (
|
|
306
|
+
("pycryptodome", lambda: _mod_available("Crypto"), _try_crypto),
|
|
307
|
+
("cryptography", lambda: _mod_available("cryptography"), _try_cryptography),
|
|
308
|
+
("openssl", lambda: _load_libcrypto() is not None, _try_openssl),
|
|
309
|
+
("pure-python", lambda: True, _pure_decrypt_cbc),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def backend_name() -> str:
|
|
314
|
+
"""Name of the backend that decrypt_cbc will use, without decrypting."""
|
|
315
|
+
for name, avail, _fn in _BACKENDS:
|
|
316
|
+
try:
|
|
317
|
+
if avail():
|
|
318
|
+
return name
|
|
319
|
+
except Exception: # noqa: BLE001 - an unusable backend is not the one to name
|
|
320
|
+
continue
|
|
321
|
+
return "pure-python"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def decrypt_cbc(key: bytes, iv: bytes, data: bytes) -> bytes:
|
|
325
|
+
"""AES-256-CBC decrypt, using the first backend that is available."""
|
|
326
|
+
last = None
|
|
327
|
+
for _name, avail, fn in _BACKENDS:
|
|
328
|
+
try:
|
|
329
|
+
if not avail():
|
|
330
|
+
continue
|
|
331
|
+
return fn(key, iv, data)
|
|
332
|
+
except ImportError:
|
|
333
|
+
continue
|
|
334
|
+
except Exception as exc: # noqa: BLE001 # pragma: no cover - the next backend gets its turn
|
|
335
|
+
last = exc
|
|
336
|
+
continue
|
|
337
|
+
raise RuntimeError("no AES backend succeeded: %r" % last)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
# FIPS-197 Appendix C.3 known-answer test for AES-256, run against every
|
|
342
|
+
# backend that is available on this host (all must agree with the vector).
|
|
343
|
+
key = bytes.fromhex(
|
|
344
|
+
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
|
345
|
+
)
|
|
346
|
+
ct = bytes.fromhex("8ea2b7ca516745bfeafc49904b496089")
|
|
347
|
+
pt = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
348
|
+
for name, avail, fn in _BACKENDS:
|
|
349
|
+
try:
|
|
350
|
+
ok = avail()
|
|
351
|
+
except Exception: # noqa: BLE001 - a self-test reports on backends, it does not need them
|
|
352
|
+
ok = False
|
|
353
|
+
if not ok:
|
|
354
|
+
print("%-12s: not available" % name)
|
|
355
|
+
continue
|
|
356
|
+
got = fn(key, b"\x00" * 16, ct)
|
|
357
|
+
assert got == pt, "%s KAT FAIL: %s" % (name, got.hex())
|
|
358
|
+
print("%-12s: AES-256 KAT OK" % name)
|
|
359
|
+
print("selected backend:", backend_name())
|
jkctl/cli/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The command-line interface.
|
|
2
|
+
|
|
3
|
+
The package is one module per concern, and one module per group of commands:
|
|
4
|
+
|
|
5
|
+
* :mod:`jkctl.cli.main` -- parse, open what the command needs, run it;
|
|
6
|
+
* :mod:`jkctl.cli.parser` -- the root parser and the shared options;
|
|
7
|
+
* :mod:`jkctl.cli.commands` -- the command groups, and the table that maps a
|
|
8
|
+
typed word to the function that runs it;
|
|
9
|
+
* :mod:`jkctl.cli.target` -- which port, and which unit on it;
|
|
10
|
+
* :mod:`jkctl.cli.output` -- tables, shared formats, the one prompt;
|
|
11
|
+
* :mod:`jkctl.cli.report` -- progress on a terminal;
|
|
12
|
+
* :mod:`jkctl.cli.exits` -- the process exit codes.
|
|
13
|
+
|
|
14
|
+
What a subcommand *is* -- :class:`~devicectl.cli.command.Command` and the
|
|
15
|
+
:class:`~devicectl.cli.command.Need` it declares -- comes from
|
|
16
|
+
``devicectl-core``, along with the three exit codes that mean the same thing
|
|
17
|
+
in every program of this shape.
|
|
18
|
+
|
|
19
|
+
To add a command: write the handler in the right module of
|
|
20
|
+
:mod:`jkctl.cli.commands`, describe it in that module's ``add_parsers``, and
|
|
21
|
+
name it in that module's ``COMMANDS``.
|
|
22
|
+
|
|
23
|
+
Messages on stderr open with ``error:`` when the command failed, ``warning:``
|
|
24
|
+
when it carried on regardless, and ``note:`` for an advisory -- the same two
|
|
25
|
+
words argparse prints, so a script can grep for one prefix instead of several.
|
|
26
|
+
A declined prompt says ``Aborted.``, and a command that simply found nothing
|
|
27
|
+
says so in a sentence; neither is a malfunction.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from jkctl.cli.exits import (
|
|
31
|
+
EXIT_ABORTED,
|
|
32
|
+
EXIT_ERROR,
|
|
33
|
+
EXIT_INCOMPATIBLE,
|
|
34
|
+
EXIT_INTERRUPTED,
|
|
35
|
+
EXIT_OK,
|
|
36
|
+
EXIT_UPDATE_FAILED,
|
|
37
|
+
)
|
|
38
|
+
from jkctl.cli.main import main
|
|
39
|
+
from jkctl.cli.parser import (
|
|
40
|
+
DEFAULT_ACTIONS,
|
|
41
|
+
build_parser,
|
|
42
|
+
insert_default_action,
|
|
43
|
+
insert_default_command,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"DEFAULT_ACTIONS",
|
|
48
|
+
"EXIT_ABORTED",
|
|
49
|
+
"EXIT_ERROR",
|
|
50
|
+
"EXIT_INCOMPATIBLE",
|
|
51
|
+
"EXIT_INTERRUPTED",
|
|
52
|
+
"EXIT_OK",
|
|
53
|
+
"EXIT_UPDATE_FAILED",
|
|
54
|
+
"build_parser",
|
|
55
|
+
"insert_default_action",
|
|
56
|
+
"insert_default_command",
|
|
57
|
+
"main",
|
|
58
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The command groups, and the table that maps a typed word to a handler.
|
|
2
|
+
|
|
3
|
+
Each module here owns one group: the handlers, and the ``add_parsers`` that
|
|
4
|
+
describes them to argparse. Adding a command means adding a function and two
|
|
5
|
+
lines to the module's own ``COMMANDS`` -- nothing outside this package changes.
|
|
6
|
+
|
|
7
|
+
``GROUPS`` is ordered, and that order is the order of ``jkctl --help``: find
|
|
8
|
+
the unit first, then read it, then change its configuration, then the acts
|
|
9
|
+
that reconfigure or restart the board itself, then the tools for when it will
|
|
10
|
+
not answer at all.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from devicectl.cli.command import Command
|
|
16
|
+
|
|
17
|
+
from jkctl.cli.commands import (
|
|
18
|
+
config,
|
|
19
|
+
controls,
|
|
20
|
+
devices,
|
|
21
|
+
diag,
|
|
22
|
+
firmware,
|
|
23
|
+
history,
|
|
24
|
+
protocols,
|
|
25
|
+
registers,
|
|
26
|
+
settings,
|
|
27
|
+
status,
|
|
28
|
+
ui,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
GROUPS = (
|
|
32
|
+
ui,
|
|
33
|
+
devices,
|
|
34
|
+
status,
|
|
35
|
+
registers,
|
|
36
|
+
history,
|
|
37
|
+
settings,
|
|
38
|
+
controls,
|
|
39
|
+
protocols,
|
|
40
|
+
firmware,
|
|
41
|
+
diag,
|
|
42
|
+
config,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
COMMANDS: dict[str, Command] = {}
|
|
46
|
+
for _group in GROUPS:
|
|
47
|
+
for _name, _command in _group.COMMANDS.items():
|
|
48
|
+
assert _name not in COMMANDS, f"two groups claim the command {_name!r}"
|
|
49
|
+
COMMANDS[_name] = _command
|
|
50
|
+
|
|
51
|
+
__all__ = ["COMMANDS", "GROUPS"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The configuration file: showing it, finding it, and writing a first one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from devicectl.cli.command import Command, Need
|
|
9
|
+
from devicectl.cli.output import may_overwrite, print_rows, print_table
|
|
10
|
+
|
|
11
|
+
from jkctl.cli.exits import EXIT_ERROR, EXIT_OK
|
|
12
|
+
from jkctl.config import (
|
|
13
|
+
EXAMPLE_CONFIG,
|
|
14
|
+
default_config_path,
|
|
15
|
+
load_config,
|
|
16
|
+
)
|
|
17
|
+
from jkctl.device import Device
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_config(device: Device | None, args: argparse.Namespace) -> int:
|
|
21
|
+
"""Dispatch one ``jkctl config`` action."""
|
|
22
|
+
return {"show": _show, "path": _path, "init": _init}[args.action](args)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _show(args: argparse.Namespace) -> int:
|
|
26
|
+
"""Print what the configuration file actually says, ready to be pasted."""
|
|
27
|
+
config = load_config(args.config)
|
|
28
|
+
print(
|
|
29
|
+
f"file: {config.path}"
|
|
30
|
+
f"{'' if config.path and config.path.is_file() else ' (does not exist)'}\n"
|
|
31
|
+
)
|
|
32
|
+
rows = [
|
|
33
|
+
(name, str(value))
|
|
34
|
+
for name, value in (
|
|
35
|
+
("port", config.port),
|
|
36
|
+
("baud", config.baud),
|
|
37
|
+
("id", config.id),
|
|
38
|
+
("timeout", config.timeout),
|
|
39
|
+
("retries", config.retries),
|
|
40
|
+
("addr_offset", config.addr_offset),
|
|
41
|
+
)
|
|
42
|
+
if value is not None
|
|
43
|
+
]
|
|
44
|
+
if rows:
|
|
45
|
+
print_rows("defaults", rows)
|
|
46
|
+
print()
|
|
47
|
+
if config.devices:
|
|
48
|
+
print_table(
|
|
49
|
+
["DEVICE", "PORT", "BAUD", "ID"],
|
|
50
|
+
[
|
|
51
|
+
[d.name, d.port or "-", str(d.baud or "-"), str(d.id or "-")]
|
|
52
|
+
for d in config.devices.values()
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
elif not rows:
|
|
56
|
+
print("Nothing configured; every command uses the built-in defaults.")
|
|
57
|
+
return EXIT_OK
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _path(args: argparse.Namespace) -> int:
|
|
61
|
+
"""Print the path the configuration is read from."""
|
|
62
|
+
print(args.config or default_config_path())
|
|
63
|
+
return EXIT_OK
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _init(args: argparse.Namespace) -> int:
|
|
67
|
+
"""Write a commented example configuration."""
|
|
68
|
+
path = args.config or default_config_path()
|
|
69
|
+
if not may_overwrite(path, yes=args.yes):
|
|
70
|
+
return EXIT_ERROR
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
|
|
73
|
+
print(f"Wrote {path}", file=sys.stderr)
|
|
74
|
+
return EXIT_OK
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add_parsers(
|
|
78
|
+
sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Add this group's commands to the root parser."""
|
|
81
|
+
p = sub.add_parser("config", help="the optional jk.toml settings file")
|
|
82
|
+
actions = p.add_subparsers(dest="action", metavar="ACTION", required=True)
|
|
83
|
+
for name, help_text in (
|
|
84
|
+
("show", "print what the file configures"),
|
|
85
|
+
("path", "print where the file is read from"),
|
|
86
|
+
("init", "write a commented example file"),
|
|
87
|
+
):
|
|
88
|
+
sp = actions.add_parser(name, help=help_text, parents=[common])
|
|
89
|
+
if name == "init":
|
|
90
|
+
sp.add_argument(
|
|
91
|
+
"-y",
|
|
92
|
+
"--yes",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="overwrite an existing file without asking",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
COMMANDS: dict[str, Command] = {
|
|
99
|
+
"config": Command(cmd_config, Need.NOTHING, default_action="show"),
|
|
100
|
+
}
|