evocode 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.
- evocode/__init__.py +13 -0
- evocode/caf.py +86 -0
- evocode/catalog.py +127 -0
- evocode/cli.py +680 -0
- evocode/coding.py +106 -0
- evocode/consent.py +32 -0
- evocode/fsc.py +274 -0
- evocode/hsfz.py +193 -0
- evocode/keys.py +137 -0
- evocode/l3.py +39 -0
- evocode/psdz.py +171 -0
- evocode/root.py +76 -0
- evocode/srec.py +98 -0
- evocode/swdl.py +423 -0
- evocode/uds.py +154 -0
- evocode-0.1.0.dist-info/METADATA +353 -0
- evocode-0.1.0.dist-info/RECORD +22 -0
- evocode-0.1.0.dist-info/WHEEL +5 -0
- evocode-0.1.0.dist-info/entry_points.txt +2 -0
- evocode-0.1.0.dist-info/licenses/LICENSE +201 -0
- evocode-0.1.0.dist-info/licenses/NOTICE +13 -0
- evocode-0.1.0.dist-info/top_level.txt +1 -0
evocode/cli.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""evocode — command-line interface.
|
|
3
|
+
|
|
4
|
+
Reads are free; ``write`` is gated and defaults to a dry run. See ``evocode <cmd> --help``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Tuple
|
|
14
|
+
|
|
15
|
+
from . import __version__, coding, consent, l3, srec
|
|
16
|
+
from .hsfz import Hsfz, TGT_HU
|
|
17
|
+
from .uds import Uds, UdsError
|
|
18
|
+
|
|
19
|
+
# coding write choreography constants (see docs/protocol.md)
|
|
20
|
+
SESSION_EXTENDED = 0x03
|
|
21
|
+
SESSION_CODING = 0x41
|
|
22
|
+
SA_SEED_LEVEL = 0x01
|
|
23
|
+
SA_KEY_LEVEL = 0x02
|
|
24
|
+
RID_CHECK_CODING = 0x0F01
|
|
25
|
+
DID_CPS = 0x37FE
|
|
26
|
+
CODING_DGS = range(0x3000, 0x3011) # user datagroups + CAF_ID + signature
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_set(s: str) -> Tuple[int, int, int, int, int]:
|
|
30
|
+
p = s.split(",")
|
|
31
|
+
dg, off, val = int(p[0], 0), int(p[1], 0), int(p[2], 0)
|
|
32
|
+
mask = int(p[3], 0) if len(p) > 3 else 0xFF
|
|
33
|
+
shift = int(p[4], 0) if len(p) > 4 else 0
|
|
34
|
+
return dg, off, val, mask, shift
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_order(mem: srec.Mem, scheme: coding.Scheme):
|
|
38
|
+
"""Datagroups present in mem, signature datagroup LAST, with their bytes."""
|
|
39
|
+
present = sorted({addr >> 8 for addr in mem})
|
|
40
|
+
order = [dg for dg in present if dg != scheme.sig_dg]
|
|
41
|
+
if scheme.sig_dg in present:
|
|
42
|
+
order.append(scheme.sig_dg)
|
|
43
|
+
return order, srec.pages(mem, order)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---- commands ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def cmd_identify(a) -> int:
|
|
49
|
+
with Hsfz(a.host) as h:
|
|
50
|
+
u = Uds(h, a.target)
|
|
51
|
+
vin = u.read_vin()
|
|
52
|
+
svk = u.read_svk()
|
|
53
|
+
print("VIN : %s" % vin)
|
|
54
|
+
print("BTLD: %s" % (svk.btld or "?"))
|
|
55
|
+
for c in svk.cafds:
|
|
56
|
+
print("CAFD: %s" % c)
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_read(a) -> int:
|
|
61
|
+
mem: srec.Mem = {}
|
|
62
|
+
chunks = []
|
|
63
|
+
with Hsfz(a.host) as h:
|
|
64
|
+
u = Uds(h, a.target)
|
|
65
|
+
vin = u.read_vin()
|
|
66
|
+
for dg in CODING_DGS:
|
|
67
|
+
try:
|
|
68
|
+
page = u.read_did(dg)
|
|
69
|
+
except UdsError:
|
|
70
|
+
continue # datagroup absent on this variant
|
|
71
|
+
base = dg << 8
|
|
72
|
+
for i, b in enumerate(page):
|
|
73
|
+
mem[base + i] = b
|
|
74
|
+
chunks.append((base, len(page), 3)) # S2: 24-bit address, BMW's convention
|
|
75
|
+
meta = {"header": "S0030000FC", "chunks": chunks,
|
|
76
|
+
"trailer": [srec.count_record(len(chunks)), "S804000000FB"]}
|
|
77
|
+
Path(a.out).write_text(srec.emit(mem, meta))
|
|
78
|
+
print("VIN %s: backed up %d datagroups -> %s" % (vin, len(chunks), a.out))
|
|
79
|
+
print("Keep this file: writing it back is a clean, already-signed revert.")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_verify(a) -> int:
|
|
84
|
+
mem, _ = srec.parse(Path(a.ncd).read_text())
|
|
85
|
+
ok = coding.verify(mem, coding.load_key(a.key), coding.SCHEMES[a.scheme])
|
|
86
|
+
print(json.dumps({"scheme": a.scheme, "signature_valid": ok}, indent=2))
|
|
87
|
+
return 0 if ok else 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_sign(a) -> int:
|
|
91
|
+
mem, meta = srec.parse(Path(a.ncd_in).read_text())
|
|
92
|
+
key = coding.load_key(a.key)
|
|
93
|
+
scheme = coding.SCHEMES[a.scheme]
|
|
94
|
+
for s in a.set:
|
|
95
|
+
srec.set_byte(mem, *_parse_set(s))
|
|
96
|
+
sig = coding.resign(mem, key, scheme)
|
|
97
|
+
if not coding.verify(mem, key, scheme):
|
|
98
|
+
print("ERROR: self-verify failed after signing", file=sys.stderr)
|
|
99
|
+
return 1
|
|
100
|
+
Path(a.ncd_out).write_text(srec.emit(mem, meta))
|
|
101
|
+
print(json.dumps({"edits": len(a.set), "signature": sig.hex(), "out": a.ncd_out}, indent=2))
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_decode(a) -> int:
|
|
106
|
+
from . import catalog
|
|
107
|
+
mem, _ = srec.parse(Path(a.ncd).read_text())
|
|
108
|
+
params = catalog.load(a.catalog)
|
|
109
|
+
want = set(a.only) if a.only else None
|
|
110
|
+
shown = 0
|
|
111
|
+
for p, raw, label in catalog.decode(mem, params):
|
|
112
|
+
if want and p.name not in want and (p.token or "") not in want:
|
|
113
|
+
continue
|
|
114
|
+
lab = " = %s" % label if label else ""
|
|
115
|
+
dflt = " (default %s)" % p.default if p.default is not None else ""
|
|
116
|
+
chg = " [non-default]" if p.default is not None and raw != p.default else ""
|
|
117
|
+
print("%-32s dg=0x%04X byte=%-3d mask=0x%02X raw=%d%s%s%s"
|
|
118
|
+
% (p.name, p.dg, p.byte, p.mask, raw, lab, dflt, chg))
|
|
119
|
+
shown += 1
|
|
120
|
+
if not shown:
|
|
121
|
+
print("(no catalog parameters found in this NCD)")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_write(a) -> int:
|
|
126
|
+
mem, _ = srec.parse(Path(a.ncd).read_text())
|
|
127
|
+
key = coding.load_key(a.key)
|
|
128
|
+
scheme = coding.SCHEMES[a.scheme]
|
|
129
|
+
if not coding.verify(mem, key, scheme):
|
|
130
|
+
print("ERROR: NCD signature does not verify locally. Re-sign with `evocode sign` first.", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
print("local signature check: OK")
|
|
133
|
+
order, pages = _write_order(mem, scheme)
|
|
134
|
+
|
|
135
|
+
if not a.commit:
|
|
136
|
+
print("\n-- DRY RUN -- would send (no bytes written):")
|
|
137
|
+
print(" 10 %02X ; 10 %02X enter extended + coding session" % (SESSION_EXTENDED, SESSION_CODING))
|
|
138
|
+
print(" 27 %02X -> seed ; 27 %02X <key> SecurityAccess L3" % (SA_SEED_LEVEL, SA_KEY_LEVEL))
|
|
139
|
+
for dg in order:
|
|
140
|
+
print(" 2E %02X %02X (%d bytes)%s" % (dg >> 8, dg & 0xFF, len(pages[dg]),
|
|
141
|
+
" <- signature LAST" if dg == scheme.sig_dg else ""))
|
|
142
|
+
print(" 31 01 %02X %02X CheckCodingSignature ; 2E 37 FE CPS ; 11 01 reset"
|
|
143
|
+
% (RID_CHECK_CODING >> 8, RID_CHECK_CODING & 0xFF))
|
|
144
|
+
print("\nRehearsal only. Re-run with --commit and --l3-key to write.")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
if not a.l3_key:
|
|
148
|
+
print("ERROR: --commit needs --l3-key (your recovered L3 SecurityAccess key)", file=sys.stderr)
|
|
149
|
+
return 2
|
|
150
|
+
consent.owned_vin() # fail early if env not set
|
|
151
|
+
l3_key = json.loads(Path(a.l3_key).read_text())
|
|
152
|
+
with Hsfz(a.host) as h:
|
|
153
|
+
u = Uds(h, a.target)
|
|
154
|
+
vin = u.read_vin()
|
|
155
|
+
consent.assert_owned(vin) # hard stop if not the car you own
|
|
156
|
+
print("connected VIN %s confirmed" % vin)
|
|
157
|
+
cps_now = u.read_did(DID_CPS)
|
|
158
|
+
u.session(SESSION_EXTENDED)
|
|
159
|
+
u.session(SESSION_CODING)
|
|
160
|
+
seed = u.request_seed(SA_SEED_LEVEL, a.user_id)
|
|
161
|
+
u.send_key(SA_KEY_LEVEL, l3.sign_seed(seed, l3_key, user_id=a.user_id))
|
|
162
|
+
print("SecurityAccess granted")
|
|
163
|
+
for dg in order:
|
|
164
|
+
u.write_did(dg, pages[dg])
|
|
165
|
+
print(" wrote 0x%04X (%d bytes)" % (dg, len(pages[dg])))
|
|
166
|
+
resp = u.routine(0x01, RID_CHECK_CODING)
|
|
167
|
+
status = resp[4:] # 71 01 0F 01 <status...>
|
|
168
|
+
if status and any(status):
|
|
169
|
+
print("ABORT: CheckCodingSignature not OK (%s). CPS not written, no reset. "
|
|
170
|
+
"Read back and decide; revert = write your backup NCD." % resp.hex(), file=sys.stderr)
|
|
171
|
+
return 3
|
|
172
|
+
if a.cps:
|
|
173
|
+
cps = bytes.fromhex(a.cps)
|
|
174
|
+
elif cps_now and all(b == 0xFF for b in cps_now):
|
|
175
|
+
cps = vin[-7:].encode("ascii") # NBT EVO CPS = last 7 of VIN; restore if erased
|
|
176
|
+
print("CPS was erased; restoring VIN-short: %s" % cps.decode())
|
|
177
|
+
else:
|
|
178
|
+
cps = cps_now
|
|
179
|
+
u.write_did(DID_CPS, cps)
|
|
180
|
+
print("CPS read-back: %s" % u.read_did(DID_CPS).hex())
|
|
181
|
+
u.reset(0x01)
|
|
182
|
+
print("reset issued; the HU reboots and re-verifies the new coding")
|
|
183
|
+
print("\nDone. Read it back and diff, and watch the HMI come up. Revert = `evocode write --ncd <backup>`.")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_recover_key(a) -> int:
|
|
188
|
+
import warnings
|
|
189
|
+
if not a.psdzdata:
|
|
190
|
+
print("set --psdzdata (or NBTEVO_PSDZDATA) to your extracted psdzdata/ root", file=sys.stderr)
|
|
191
|
+
return 2
|
|
192
|
+
consent.owned_vin() # owner action (recovering your own key)
|
|
193
|
+
warnings.filterwarnings("ignore", message=".*PKCS#12.*")
|
|
194
|
+
try:
|
|
195
|
+
from . import keys
|
|
196
|
+
key = keys.recover_from_install(a.psdzdata, a.btld, a.kind,
|
|
197
|
+
resource_jar=a.resource_jar, logo=a.bmw_logo, mini=a.bmw_mini_logo)
|
|
198
|
+
except ImportError:
|
|
199
|
+
print("recover-key needs the crypto extra: pip install 'evocode[crypto]'", file=sys.stderr)
|
|
200
|
+
return 2
|
|
201
|
+
except (FileNotFoundError, KeyError, ValueError) as exc:
|
|
202
|
+
print("recovery failed: %s" % exc, file=sys.stderr)
|
|
203
|
+
return 1
|
|
204
|
+
out = Path(a.out).expanduser()
|
|
205
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
out.write_text(json.dumps(key, indent=2))
|
|
207
|
+
try:
|
|
208
|
+
out.chmod(0o600)
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|
|
211
|
+
warn = " [TEST KEY — not valid on a customer ECU]" if key["test_key"] else ""
|
|
212
|
+
print("recovered %s key for BTLD %s (%s/%s, e=%d)%s"
|
|
213
|
+
% (key["kind"].upper(), key["btld"], key["scheme"], key["digest"], key["public_exponent"], warn))
|
|
214
|
+
print(" modulus (public): %s…" % key["modulus_hex"][:24])
|
|
215
|
+
print(" -> %s (keep the private exponent secret; gitignore it)" % out)
|
|
216
|
+
if a.kind == "ncd":
|
|
217
|
+
print(" verify it: evocode verify --ncd <your backup>.ncd --key %s" % out)
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_build_catalog(a) -> int:
|
|
222
|
+
import re
|
|
223
|
+
if not a.psdzdata:
|
|
224
|
+
print("set --psdzdata (or NBTEVO_PSDZDATA) to your extracted psdzdata/ root", file=sys.stderr)
|
|
225
|
+
return 2
|
|
226
|
+
consent.owned_vin() # owner action (decrypts with your key)
|
|
227
|
+
try:
|
|
228
|
+
from . import caf, catalog
|
|
229
|
+
xml = caf.decrypt_caf(a.caf, a.psdzdata, resource_jar=a.resource_jar,
|
|
230
|
+
logo=a.bmw_logo, mini=a.bmw_mini_logo)
|
|
231
|
+
except ImportError:
|
|
232
|
+
print("build-catalog needs the crypto extra and openssl: pip install 'evocode[crypto]'", file=sys.stderr)
|
|
233
|
+
return 2
|
|
234
|
+
except (FileNotFoundError, KeyError, ValueError, RuntimeError, OSError) as exc:
|
|
235
|
+
print("build-catalog failed: %s" % exc, file=sys.stderr)
|
|
236
|
+
return 1
|
|
237
|
+
doc = catalog.build_skeleton(xml)
|
|
238
|
+
m = re.search(r"cafd_([0-9a-fA-F]+)\.caf\.([0-9_]+)", Path(a.caf).name)
|
|
239
|
+
if m:
|
|
240
|
+
doc["cafd"], doc["version"] = m.group(1).upper(), m.group(2).replace("_", ".")
|
|
241
|
+
Path(a.out).write_text(json.dumps(doc, indent=1, ensure_ascii=False))
|
|
242
|
+
print("CAFD %s v%s: wrote %d coordinates -> %s"
|
|
243
|
+
% (doc.get("cafd") or "?", doc.get("version") or "?", len(doc["parameters"]), a.out))
|
|
244
|
+
print(" coordinates/masks/defaults are factual; parameter NAMES are blank — add them from the")
|
|
245
|
+
print(" firmware-token layer / community (catalogs/README.md).")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---- FSC (owner-CA activations) ------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _fsc_modulus(a) -> int:
|
|
252
|
+
if getattr(a, "key", None):
|
|
253
|
+
return int(json.loads(Path(a.key).read_text())["n_hex"], 16)
|
|
254
|
+
if getattr(a, "blobb", None):
|
|
255
|
+
return int.from_bytes(Path(a.blobb).read_bytes(), "little")
|
|
256
|
+
raise SystemExit("need --key (owner key JSON) or --blobb (128-byte LE modulus)")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_fsc_keygen(a) -> int:
|
|
260
|
+
from . import fsc as F
|
|
261
|
+
key = F.keygen(a.bits)
|
|
262
|
+
Path(a.out).write_text(json.dumps(key, indent=2))
|
|
263
|
+
try:
|
|
264
|
+
Path(a.out).chmod(0o600)
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
267
|
+
n = int(key["n_hex"], 16)
|
|
268
|
+
if a.blobb:
|
|
269
|
+
Path(a.blobb).write_bytes(F.blobB_le(n))
|
|
270
|
+
print("owner anchor: e=3, %d-bit, fp %s -> %s (PRIVATE — your FSC root of trust)"
|
|
271
|
+
% (key["bits"], F.fingerprint(n), a.out))
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_fsc_mint(a) -> int:
|
|
276
|
+
from . import fsc as F
|
|
277
|
+
key = json.loads(Path(a.key).read_text())
|
|
278
|
+
n, d = int(key["n_hex"], 16), int(key["d_hex"], 16)
|
|
279
|
+
tok = F.mint(n, d, int(a.appno, 0), upgrade_idx=a.upgrade_index, vin=(a.vin.encode() if a.vin else None))
|
|
280
|
+
if not F.verify(tok, n)[0]:
|
|
281
|
+
print("ERROR: minted FSC failed self-verify", file=sys.stderr)
|
|
282
|
+
return 1
|
|
283
|
+
Path(a.out).write_bytes(tok)
|
|
284
|
+
print("minted AppNo %s (%d B) -> %s; self-verify OK" % (a.appno, len(tok), a.out))
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_fsc_verify(a) -> int:
|
|
289
|
+
from . import fsc as F
|
|
290
|
+
ok, msg = F.verify(Path(a.fsc).read_bytes(), _fsc_modulus(a))
|
|
291
|
+
print(json.dumps({"accepted": ok, "detail": msg}, indent=2))
|
|
292
|
+
return 0 if ok else 1
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_fsc_parse(a) -> int:
|
|
296
|
+
from . import fsc as F
|
|
297
|
+
print(json.dumps(F.parse(Path(a.fsc).read_bytes()), indent=2))
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def cmd_fsc_fingerprint(a) -> int:
|
|
302
|
+
from . import fsc as F
|
|
303
|
+
n = _fsc_modulus(a)
|
|
304
|
+
print("%s (e=3, %d-bit)" % (F.fingerprint(n), n.bit_length()))
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _fsc_anchor(spec: str):
|
|
309
|
+
from . import fsc as F
|
|
310
|
+
label, _, path = spec.partition("=")
|
|
311
|
+
if not path:
|
|
312
|
+
label, path = "", label
|
|
313
|
+
raw = Path(path).read_bytes()
|
|
314
|
+
try:
|
|
315
|
+
n = int(json.loads(raw.decode())["n_hex"], 16)
|
|
316
|
+
except (ValueError, UnicodeDecodeError, KeyError):
|
|
317
|
+
if len(raw) != 128:
|
|
318
|
+
raise SystemExit("%s: not key JSON and not a 128-byte blobB" % path)
|
|
319
|
+
n = int.from_bytes(raw, "little")
|
|
320
|
+
return (label or path), n, F.fingerprint(n)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def cmd_fsc_whosigned(a) -> int:
|
|
324
|
+
from . import fsc as F
|
|
325
|
+
toks = [Path(f).read_bytes() for f in a.fsc]
|
|
326
|
+
for f, t in zip(a.fsc, toks):
|
|
327
|
+
b = F.parse(t)
|
|
328
|
+
print(" %s: AppNo %s, VIN %s" % (f, b["appno"], b["vin"]))
|
|
329
|
+
matched = None
|
|
330
|
+
for spec in a.anchor:
|
|
331
|
+
label, n, fp = _fsc_anchor(spec)
|
|
332
|
+
res = [F.verify(t, n)[0] for t in toks]
|
|
333
|
+
tag = "ALL" if all(res) else ("some" if any(res) else "none")
|
|
334
|
+
print(" [%-4s] %s (fp %s)" % (tag, label, fp))
|
|
335
|
+
if all(res) and matched is None:
|
|
336
|
+
matched = (label, n)
|
|
337
|
+
if matched:
|
|
338
|
+
print("==> signed by %s (fp %s). That is the installed anchor." % (matched[0], F.fingerprint(matched[1])))
|
|
339
|
+
return 0
|
|
340
|
+
if not a.recover:
|
|
341
|
+
print("==> no candidate anchor verifies all FSCs; add --anchor or pass --recover")
|
|
342
|
+
return 1
|
|
343
|
+
n = F.recover_modulus(toks)
|
|
344
|
+
if all(F.verify(t, n)[0] for t in toks):
|
|
345
|
+
print("==> signed by an UNKNOWN anchor (not a candidate): fp %s, %d-bit.\n"
|
|
346
|
+
" Someone else set this unit's trust; your overwrite would replace it." % (F.fingerprint(n), n.bit_length()))
|
|
347
|
+
return 0
|
|
348
|
+
print("==> recovery inconclusive — FSCs may not share one anchor", file=sys.stderr)
|
|
349
|
+
return 2
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_fsc_find_blobb(a) -> int:
|
|
353
|
+
from . import fsc as F
|
|
354
|
+
off, n = F.find_blobb(Path(a.nbtcarhu).read_bytes())
|
|
355
|
+
print("blobB @ file offset 0x%06X (e=3, %d-bit, fp %s)" % (off, n.bit_length(), F.fingerprint(n)))
|
|
356
|
+
if a.save_blobb:
|
|
357
|
+
Path(a.save_blobb).write_bytes(F.blobB_le(n))
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def cmd_fsc_patch_blobb(a) -> int:
|
|
362
|
+
from . import fsc as F
|
|
363
|
+
data = Path(a.nbtcarhu).read_bytes()
|
|
364
|
+
off = int(a.offset, 0) if a.offset else F.find_blobb(data)[0]
|
|
365
|
+
old = int.from_bytes(data[off:off + 128], "little")
|
|
366
|
+
key = json.loads(Path(a.key).read_text())
|
|
367
|
+
n = int(key["n_hex"], 16)
|
|
368
|
+
if a.backup:
|
|
369
|
+
Path(a.backup).write_bytes(F.blobB_le(old))
|
|
370
|
+
Path(a.out).write_bytes(F.patch_blobb(data, off, n))
|
|
371
|
+
print("patched blobB @0x%06X: %s -> %s -> %s" % (off, F.fingerprint(old), F.fingerprint(n), a.out))
|
|
372
|
+
print(" length-preserving; keep the backup to revert. Installing the patched NBTCarHU on the unit")
|
|
373
|
+
print(" is the flash/file-replace step (brick-risk) — see the FSC runbook.")
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_fsc_apply(a) -> int:
|
|
378
|
+
import base64
|
|
379
|
+
import glob
|
|
380
|
+
from . import fsc as F
|
|
381
|
+
SWT_RID, RCO_STORE, RCO_READ = 0x0F1F, 0x07, 0x0B # 0x07 = FREISCHALTCODE_SCHREIBEN (from F020 ODX)
|
|
382
|
+
files = sorted(glob.glob(str(Path(a.fsc_dir) / "*.fsc")))
|
|
383
|
+
toks = {F.parse(Path(f).read_bytes())["appno"]: Path(f).read_bytes() for f in files}
|
|
384
|
+
if not toks:
|
|
385
|
+
print("no .fsc files in %s (mint some first)" % a.fsc_dir, file=sys.stderr)
|
|
386
|
+
return 2
|
|
387
|
+
if not a.commit:
|
|
388
|
+
print("-- DRY RUN -- would send (no bytes written); needs the installed anchor to be YOUR key:")
|
|
389
|
+
print(" 10 03 ; 27 01 -> seed ; 27 02 <L3 key>")
|
|
390
|
+
for app, t in toks.items():
|
|
391
|
+
print(" 31 01 %04X %02X <base32 of %d-byte FSC> store %s" % (SWT_RID, RCO_STORE, len(t), app))
|
|
392
|
+
print(" then read-back (RCO 0x%02X) + 11 01 reset. Re-run with --host, --l3-key and --commit." % RCO_READ)
|
|
393
|
+
print(" NOTE: store RCO 0x07 is from the F020 SWT ODX; confirm it on a first careful apply.")
|
|
394
|
+
return 0
|
|
395
|
+
if not (a.host and a.l3_key):
|
|
396
|
+
print("ERROR: --commit needs --host and --l3-key", file=sys.stderr)
|
|
397
|
+
return 2
|
|
398
|
+
consent.owned_vin()
|
|
399
|
+
l3_key = json.loads(Path(a.l3_key).read_text())
|
|
400
|
+
from . import l3
|
|
401
|
+
from .hsfz import Hsfz
|
|
402
|
+
from .uds import Uds
|
|
403
|
+
with Hsfz(a.host) as h:
|
|
404
|
+
u = Uds(h, a.target)
|
|
405
|
+
consent.assert_owned(u.read_vin())
|
|
406
|
+
u.session(0x03)
|
|
407
|
+
seed = u.request_seed(0x01, 0x00000000)
|
|
408
|
+
u.send_key(0x02, l3.sign_seed(seed, l3_key, user_id=0x00000000))
|
|
409
|
+
print("SecurityAccess unlocked")
|
|
410
|
+
for app, t in toks.items():
|
|
411
|
+
try:
|
|
412
|
+
u.routine(0x01, SWT_RID, bytes([RCO_STORE]) + base64.b32encode(t))
|
|
413
|
+
print(" stored %s OK" % app)
|
|
414
|
+
except UdsError as e:
|
|
415
|
+
print(" stored %s FAILED: %s" % (app, e))
|
|
416
|
+
u.reset(0x01)
|
|
417
|
+
print("done; reboot + verify features. Revert = restore the stock blobB (your --backup).")
|
|
418
|
+
return 0
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def cmd_fsc_from_log(a) -> int:
|
|
422
|
+
import re
|
|
423
|
+
from . import fsc as F
|
|
424
|
+
toks = {}
|
|
425
|
+
for line in Path(a.log).read_text(errors="replace").splitlines():
|
|
426
|
+
line = line.strip()
|
|
427
|
+
if line.startswith("<--"):
|
|
428
|
+
hp = line[3:].strip()
|
|
429
|
+
if re.fullmatch(r"([0-9A-Fa-f]{2}\s+)*[0-9A-Fa-f]{2}", hp or ""):
|
|
430
|
+
b = bytes.fromhex(hp.replace(" ", ""))
|
|
431
|
+
if len(b) >= 180:
|
|
432
|
+
toks.setdefault(F.parse(b)["appno"], b[:188])
|
|
433
|
+
out = Path(a.out)
|
|
434
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
for app, t in toks.items():
|
|
436
|
+
(out / ("fsc_%s.fsc" % app)).write_bytes(t)
|
|
437
|
+
print("extracted %d FSC(s) -> %s/" % (len(toks), out))
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---- root (OPT_DEBUG) ----------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
def cmd_root_status(a) -> int:
|
|
444
|
+
from . import root
|
|
445
|
+
ports = root.check_ports(a.host)
|
|
446
|
+
any_open = any(ports.values())
|
|
447
|
+
for p, ok in sorted(ports.items()):
|
|
448
|
+
print(" port %-5d %s" % (p, "OPEN" if ok else "closed"))
|
|
449
|
+
if any_open:
|
|
450
|
+
print("OPT_DEBUG appears ENABLED (dev ports open)")
|
|
451
|
+
else:
|
|
452
|
+
print("OPT_DEBUG appears DISABLED (dev ports closed)")
|
|
453
|
+
return 0
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def cmd_root_prepare(a) -> int:
|
|
457
|
+
from . import root
|
|
458
|
+
cfg = Path(a.starter_cfg).read_text(encoding="latin-1")
|
|
459
|
+
state = root.is_enabled(cfg)
|
|
460
|
+
if state is None:
|
|
461
|
+
print("ERROR: OPT_DEBUG service not found in %s" % a.starter_cfg, file=sys.stderr)
|
|
462
|
+
return 1
|
|
463
|
+
if a.revert:
|
|
464
|
+
patched = root.unpatch(cfg)
|
|
465
|
+
label = "RUN→STOP"
|
|
466
|
+
else:
|
|
467
|
+
patched = root.patch(cfg)
|
|
468
|
+
label = "STOP→RUN"
|
|
469
|
+
Path(a.out).write_text(patched, encoding="latin-1")
|
|
470
|
+
print("OPT_DEBUG %s %s -> %s" % (label, a.starter_cfg, a.out))
|
|
471
|
+
print(" stock file is your revert — keep it safe.")
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _root_flash(a, cfg_data: bytes, label: str) -> int:
|
|
476
|
+
from . import root, swdl
|
|
477
|
+
img = swdl.build_container(cfg_data, root.STARTER_PATH, root.STARTER_NAME)
|
|
478
|
+
btld = int(a.btld, 16)
|
|
479
|
+
tgt, _nm, is_live = swdl.container_target(img)
|
|
480
|
+
|
|
481
|
+
if not a.commit:
|
|
482
|
+
print("-- DRY RUN -- %s" % label)
|
|
483
|
+
print("container : %d bytes -> %s [%s]" % (len(img), tgt, "LIVE-DIRECT" if is_live else "STAGING"))
|
|
484
|
+
print(" planned: enter bolo -> L3 auth -> SWDL transferData -> finalize -> reboot")
|
|
485
|
+
print(" LIVE-DIRECT: writes starter.cfg in place; goes live next boot.")
|
|
486
|
+
print(" A bad/wrong-version cfg fails the next boot — keep the stock for --revert.")
|
|
487
|
+
print(" Run detached (nohup/setsid) so an SSH drop can't tear the write.")
|
|
488
|
+
print("\nRe-run with --commit to flash.")
|
|
489
|
+
return 0
|
|
490
|
+
|
|
491
|
+
if not a.l3_key:
|
|
492
|
+
print("ERROR: --commit needs --l3-key (your recovered L3 SecurityAccess key)", file=sys.stderr)
|
|
493
|
+
return 2
|
|
494
|
+
consent.owned_vin() # fail early if env not set
|
|
495
|
+
l3_key = json.loads(Path(a.l3_key).read_text())
|
|
496
|
+
print("COMMIT: %s" % label)
|
|
497
|
+
with Hsfz(a.host) as h:
|
|
498
|
+
u = Uds(h, a.target)
|
|
499
|
+
vin = u.read_vin()
|
|
500
|
+
consent.assert_owned(vin)
|
|
501
|
+
print("VIN %s confirmed" % vin)
|
|
502
|
+
|
|
503
|
+
h = Hsfz(a.host)
|
|
504
|
+
wrote = False
|
|
505
|
+
try:
|
|
506
|
+
wrote = swdl.apply_flash(h, img, target=a.target, reset_timeout=a.reset_timeout,
|
|
507
|
+
key=l3_key, btld=btld, log=print)
|
|
508
|
+
finally:
|
|
509
|
+
try:
|
|
510
|
+
h.stop_tester_present()
|
|
511
|
+
except Exception:
|
|
512
|
+
pass
|
|
513
|
+
try:
|
|
514
|
+
swdl.teardown(h, target=a.target, log=print)
|
|
515
|
+
except Exception as e:
|
|
516
|
+
print(" [finalize warn] %s" % e)
|
|
517
|
+
try:
|
|
518
|
+
h.close()
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
return 0 if wrote else 1
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def cmd_root_enable(a) -> int:
|
|
525
|
+
from . import root
|
|
526
|
+
cfg = Path(a.starter_cfg).read_text(encoding="latin-1")
|
|
527
|
+
state = root.is_enabled(cfg)
|
|
528
|
+
if state is None:
|
|
529
|
+
print("ERROR: OPT_DEBUG not found in %s" % a.starter_cfg, file=sys.stderr)
|
|
530
|
+
return 1
|
|
531
|
+
if state is True:
|
|
532
|
+
print("starter.cfg already has OPT_DEBUG=RUN — using as-is")
|
|
533
|
+
patched_data = cfg.encode("latin-1")
|
|
534
|
+
else:
|
|
535
|
+
patched_data = root.patch(cfg).encode("latin-1")
|
|
536
|
+
return _root_flash(a, patched_data, "OPT_DEBUG enable (STOP→RUN)")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def cmd_root_revert(a) -> int:
|
|
540
|
+
stock_data = Path(a.stock).read_bytes()
|
|
541
|
+
return _root_flash(a, stock_data, "OPT_DEBUG revert (stock starter.cfg)")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---- parser --------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
def _target(x: str) -> int:
|
|
547
|
+
return int(x, 0)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
551
|
+
ap = argparse.ArgumentParser(prog="evocode", description=__doc__)
|
|
552
|
+
ap.add_argument("--version", action="version", version="evocode %s" % __version__)
|
|
553
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
554
|
+
|
|
555
|
+
def host_target(p):
|
|
556
|
+
p.add_argument("--host", required=True, help="HSFZ endpoint IP")
|
|
557
|
+
p.add_argument("--target", type=_target, default=TGT_HU, help="ECU diag address (default 0x63)")
|
|
558
|
+
|
|
559
|
+
p = sub.add_parser("identify", help="read SVK: BTLD, CAFD, VIN"); host_target(p); p.set_defaults(fn=cmd_identify)
|
|
560
|
+
|
|
561
|
+
p = sub.add_parser("read", help="back up live coding to an NCD"); host_target(p)
|
|
562
|
+
p.add_argument("-o", "--out", required=True); p.set_defaults(fn=cmd_read)
|
|
563
|
+
|
|
564
|
+
p = sub.add_parser("verify", help="check an NCD's signature offline")
|
|
565
|
+
p.add_argument("--ncd", required=True); p.add_argument("--key", required=True)
|
|
566
|
+
p.add_argument("--scheme", default=coding.DEFAULT_SCHEME, choices=sorted(coding.SCHEMES))
|
|
567
|
+
p.set_defaults(fn=cmd_verify)
|
|
568
|
+
|
|
569
|
+
p = sub.add_parser("sign", help="edit + re-sign an NCD offline")
|
|
570
|
+
p.add_argument("ncd_in"); p.add_argument("ncd_out"); p.add_argument("--key", required=True)
|
|
571
|
+
p.add_argument("--scheme", default=coding.DEFAULT_SCHEME, choices=sorted(coding.SCHEMES))
|
|
572
|
+
p.add_argument("--set", action="append", default=[], metavar="DG,OFF,VAL[,MASK,SHIFT]",
|
|
573
|
+
help="edit a coding byte, e.g. 0x3000,107,0x80,0x80,7")
|
|
574
|
+
p.set_defaults(fn=cmd_sign)
|
|
575
|
+
|
|
576
|
+
p = sub.add_parser("decode", help="show current values of an NCD against a catalog")
|
|
577
|
+
p.add_argument("--ncd", required=True)
|
|
578
|
+
p.add_argument("--catalog", required=True, help="per-CAFD catalog JSON (see catalogs/)")
|
|
579
|
+
p.add_argument("--only", nargs="*", help="limit to these parameter names/tokens")
|
|
580
|
+
p.set_defaults(fn=cmd_decode)
|
|
581
|
+
|
|
582
|
+
p = sub.add_parser("write", help="write a signed NCD (gated; dry-run unless --commit)"); host_target(p)
|
|
583
|
+
p.add_argument("--ncd", required=True); p.add_argument("--key", required=True)
|
|
584
|
+
p.add_argument("--scheme", default=coding.DEFAULT_SCHEME, choices=sorted(coding.SCHEMES))
|
|
585
|
+
p.add_argument("--l3-key", help="your recovered L3 SecurityAccess key (required for --commit)")
|
|
586
|
+
p.add_argument("--user-id", type=_target, default=0x00000000, help="testerId for seed+key (default 0)")
|
|
587
|
+
p.add_argument("--cps", help="CPS (0x37FE) value as hex; default keep current / restore VIN-short if erased")
|
|
588
|
+
p.add_argument("--commit", action="store_true", help="actually write (gated); omit to rehearse")
|
|
589
|
+
p.set_defaults(fn=cmd_write)
|
|
590
|
+
|
|
591
|
+
p = sub.add_parser("recover-key", help="recover your NCD/L3 signing key from your own PSdZData")
|
|
592
|
+
p.add_argument("--btld", required=True, help="bootloader id, e.g. 00001FFD (from `evocode identify`)")
|
|
593
|
+
p.add_argument("--kind", default="ncd", choices=["ncd", "l3"], help="ncd = coding signing · l3 = write unlock")
|
|
594
|
+
p.add_argument("--psdzdata", default=os.environ.get("NBTEVO_PSDZDATA"), help="your extracted psdzdata/ root")
|
|
595
|
+
p.add_argument("--resource-jar", default=os.environ.get("NBTEVO_PSDZ_RESOURCE_JAR"),
|
|
596
|
+
help="your PSdZ/bin/psdz/resource.jar (holds the logo PNGs; NOT inside psdzdata/)")
|
|
597
|
+
p.add_argument("--bmw-logo", help="instead of --resource-jar: your own bmw_logo.png")
|
|
598
|
+
p.add_argument("--bmw-mini-logo", help="…and your own bmw_mini_logo.png")
|
|
599
|
+
p.add_argument("-o", "--out", required=True)
|
|
600
|
+
p.set_defaults(fn=cmd_recover_key)
|
|
601
|
+
p = sub.add_parser("build-catalog", help="build a catalog skeleton from your own encrypted CAFD .caf")
|
|
602
|
+
p.add_argument("--caf", required=True, help="your cafd_<id>.caf.<ver> from psdzdata/swe/cafd/")
|
|
603
|
+
p.add_argument("--psdzdata", default=os.environ.get("NBTEVO_PSDZDATA"), help="your psdzdata/ root (for the keystore)")
|
|
604
|
+
p.add_argument("--resource-jar", default=os.environ.get("NBTEVO_PSDZ_RESOURCE_JAR"),
|
|
605
|
+
help="your PSdZ/bin/psdz/resource.jar (logo PNGs for the keystore password)")
|
|
606
|
+
p.add_argument("--bmw-logo", help="instead of --resource-jar: your own bmw_logo.png")
|
|
607
|
+
p.add_argument("--bmw-mini-logo", help="…and your own bmw_mini_logo.png")
|
|
608
|
+
p.add_argument("-o", "--out", required=True)
|
|
609
|
+
p.set_defaults(fn=cmd_build_catalog)
|
|
610
|
+
|
|
611
|
+
# FSC activations (owner-CA): a nested `evocode fsc <cmd>` group.
|
|
612
|
+
fp = sub.add_parser("fsc", help="FSC activations (owner-CA): keygen/mint/verify/whosigned")
|
|
613
|
+
fs = fp.add_subparsers(dest="fsc_cmd", required=True)
|
|
614
|
+
g = fs.add_parser("keygen", help="generate your own e=3 FSC trust anchor")
|
|
615
|
+
g.add_argument("-o", "--out", required=True); g.add_argument("--blobb", help="also write the 128-byte LE modulus")
|
|
616
|
+
g.add_argument("--bits", type=int, default=1024); g.set_defaults(fn=cmd_fsc_keygen)
|
|
617
|
+
g = fs.add_parser("mint", help="mint an FSC signed by your own key")
|
|
618
|
+
g.add_argument("--key", required=True); g.add_argument("--appno", required=True, help="e.g. 0x0143 (CarPlay)")
|
|
619
|
+
g.add_argument("--vin", help="9-char VIN field (omit for VIN-generic)")
|
|
620
|
+
g.add_argument("--upgrade-index", type=int, default=1); g.add_argument("-o", "--out", required=True)
|
|
621
|
+
g.set_defaults(fn=cmd_fsc_mint)
|
|
622
|
+
g = fs.add_parser("verify", help="check an FSC would be accepted under a modulus")
|
|
623
|
+
g.add_argument("--fsc", required=True); g.add_argument("--key"); g.add_argument("--blobb")
|
|
624
|
+
g.set_defaults(fn=cmd_fsc_verify)
|
|
625
|
+
g = fs.add_parser("parse", help="decode an FSC's body fields"); g.add_argument("--fsc", required=True)
|
|
626
|
+
g.set_defaults(fn=cmd_fsc_parse)
|
|
627
|
+
g = fs.add_parser("fingerprint", help="stable short id of an anchor"); g.add_argument("--key"); g.add_argument("--blobb")
|
|
628
|
+
g.set_defaults(fn=cmd_fsc_fingerprint)
|
|
629
|
+
g = fs.add_parser("whosigned", help="identify WHOSE anchor signed FSCs read off a unit")
|
|
630
|
+
g.add_argument("--fsc", required=True, action="append"); g.add_argument("--anchor", action="append", default=[])
|
|
631
|
+
g.add_argument("--recover", action="store_true"); g.set_defaults(fn=cmd_fsc_whosigned)
|
|
632
|
+
g = fs.add_parser("from-log", help="extract .fsc tokens from a GetFSC read log")
|
|
633
|
+
g.add_argument("log"); g.add_argument("-o", "--out", required=True); g.set_defaults(fn=cmd_fsc_from_log)
|
|
634
|
+
g = fs.add_parser("find-blobb", help="locate the FSC anchor in a NBTCarHU dump")
|
|
635
|
+
g.add_argument("nbtcarhu"); g.add_argument("--save-blobb", help="write the 128-byte modulus here")
|
|
636
|
+
g.set_defaults(fn=cmd_fsc_find_blobb)
|
|
637
|
+
g = fs.add_parser("patch-blobb", help="swap the anchor in a NBTCarHU dump to your own key (offline)")
|
|
638
|
+
g.add_argument("nbtcarhu"); g.add_argument("--key", required=True)
|
|
639
|
+
g.add_argument("--offset", help="blobB file offset (default: auto-locate)")
|
|
640
|
+
g.add_argument("--backup", help="write the stock 128-byte anchor here, for revert")
|
|
641
|
+
g.add_argument("-o", "--out", required=True); g.set_defaults(fn=cmd_fsc_patch_blobb)
|
|
642
|
+
g = fs.add_parser("apply", help="store minted FSCs on the car (SwtRoutine + L3; dry-run unless --commit)")
|
|
643
|
+
g.add_argument("--fsc-dir", required=True, help="directory of .fsc files (from `fsc mint`)")
|
|
644
|
+
g.add_argument("--host"); g.add_argument("--target", type=_target, default=TGT_HU)
|
|
645
|
+
g.add_argument("--l3-key"); g.add_argument("--commit", action="store_true")
|
|
646
|
+
g.set_defaults(fn=cmd_fsc_apply)
|
|
647
|
+
|
|
648
|
+
# Root access (OPT_DEBUG): `evocode root <cmd>` group.
|
|
649
|
+
rp = sub.add_parser("root", help="OPT_DEBUG root-access: status/prepare/enable/revert (see docs/root-access.md)")
|
|
650
|
+
rs = rp.add_subparsers(dest="root_cmd", required=True)
|
|
651
|
+
g = rs.add_parser("status", help="probe whether dev ports (22/23/8000) are open")
|
|
652
|
+
g.add_argument("--host", required=True); g.set_defaults(fn=cmd_root_status)
|
|
653
|
+
g = rs.add_parser("prepare", help="patch starter.cfg offline: STOP→RUN (or --revert: RUN→STOP)")
|
|
654
|
+
g.add_argument("--starter-cfg", required=True, help="stock starter.cfg from the unit")
|
|
655
|
+
g.add_argument("-o", "--out", required=True, help="patched output file")
|
|
656
|
+
g.add_argument("--revert", action="store_true", help="RUN→STOP instead"); g.set_defaults(fn=cmd_root_prepare)
|
|
657
|
+
g = rs.add_parser("enable", help="flash patched starter.cfg (dry-run unless --commit)")
|
|
658
|
+
host_target(g); g.add_argument("--l3-key", help="recovered L3 key (required for --commit)")
|
|
659
|
+
g.add_argument("--starter-cfg", required=True, help="stock starter.cfg (or pre-patched)")
|
|
660
|
+
g.add_argument("--btld", default="00001FFD"); g.add_argument("--reset-timeout", type=int, default=50)
|
|
661
|
+
g.add_argument("--commit", action="store_true"); g.set_defaults(fn=cmd_root_enable)
|
|
662
|
+
g = rs.add_parser("revert", help="flash stock starter.cfg to disable OPT_DEBUG")
|
|
663
|
+
host_target(g); g.add_argument("--l3-key", help="recovered L3 key (required for --commit)")
|
|
664
|
+
g.add_argument("--stock", required=True, help="the original stock starter.cfg")
|
|
665
|
+
g.add_argument("--btld", default="00001FFD"); g.add_argument("--reset-timeout", type=int, default=50)
|
|
666
|
+
g.add_argument("--commit", action="store_true"); g.set_defaults(fn=cmd_root_revert)
|
|
667
|
+
return ap
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def main(argv=None) -> int:
|
|
671
|
+
a = build_parser().parse_args(argv)
|
|
672
|
+
try:
|
|
673
|
+
return a.fn(a)
|
|
674
|
+
except (consent.ConsentError, UdsError) as exc:
|
|
675
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
676
|
+
return 2
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
if __name__ == "__main__":
|
|
680
|
+
raise SystemExit(main())
|