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/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""evocode — read, decode, sign and write BMW NBT EVO (QNX) coding over ENET/HSFZ.
|
|
3
|
+
|
|
4
|
+
Pure Python standard library. Platform scope: NBT EVO / QNX (iDrive ID4/ID5/ID6)
|
|
5
|
+
only — NOT MGU / ID7+ (Linux). See docs/scope.md.
|
|
6
|
+
|
|
7
|
+
The mechanism is constant across NBT EVO builds; what changes per car is data —
|
|
8
|
+
the BTLD-selected signing key and the per-CAFD bit catalog. Read the SVK first and
|
|
9
|
+
key off the unit's real BTLD + CAFD ids; never assume them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = ["__version__"]
|
evocode/caf.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Decrypt a BMW CAFD ``.caf`` into its plaintext CAFD XML.
|
|
3
|
+
|
|
4
|
+
A ``.caf`` is an XML wrapper::
|
|
5
|
+
|
|
6
|
+
<caf isEncrypted="true" isCompressed="true"><encryptedData>BASE64(CMS)</encryptedData></caf>
|
|
7
|
+
|
|
8
|
+
The inner CMS is a PKCS#7 EnvelopedData encrypted to the **Codier-Encrypt** certificate, whose RSA
|
|
9
|
+
private key lives (alongside the E-SYS key) in your ``sec_security.lic`` keystore. The decrypted
|
|
10
|
+
payload is **gzip**-compressed CAFD XML. So: recover the Codier-Encrypt key (derived keystore
|
|
11
|
+
password, see :mod:`evocode.psdz`), CMS-decrypt, gunzip.
|
|
12
|
+
|
|
13
|
+
Needs the ``crypto`` extra and an ``openssl`` binary (OpenSSL 3.x): cryptography exposes only one of
|
|
14
|
+
the keystore's two private keys and has no EnvelopedData-decrypt API, so the second key and the CMS
|
|
15
|
+
decrypt go through ``openssl``. Everything else is pure stdlib.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import gzip
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
import xml.etree.ElementTree as ET
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import psdz
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ln(tag: str) -> str:
|
|
32
|
+
return tag.rsplit("}", 1)[-1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cms_from_caf(caf_path) -> bytes:
|
|
36
|
+
"""The DER CMS EnvelopedData from a .caf's <encryptedData> (or the file itself if already CMS)."""
|
|
37
|
+
data = Path(caf_path).read_bytes()
|
|
38
|
+
try:
|
|
39
|
+
root = ET.fromstring(data)
|
|
40
|
+
except ET.ParseError:
|
|
41
|
+
return data
|
|
42
|
+
ed = next((e for e in root.iter() if _ln(e.tag) == "encryptedData"), None)
|
|
43
|
+
if ed is None:
|
|
44
|
+
raise ValueError("%s is not an encrypted .caf (no <encryptedData>)" % caf_path)
|
|
45
|
+
return base64.b64decode("".join((ed.text or "").split()))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decrypt_caf(caf_path, psdzdata, *, resource_jar=None, logo=None, mini=None,
|
|
49
|
+
recipient="Codier-Encrypt") -> bytes:
|
|
50
|
+
"""Return the plaintext CAFD XML bytes for an encrypted ``.caf``."""
|
|
51
|
+
from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key, pkcs12
|
|
52
|
+
|
|
53
|
+
sec = Path(psdzdata) / "security"
|
|
54
|
+
lic = sec / "sec_security.lic"
|
|
55
|
+
password = psdz.derive_password(lic, resource_jar=resource_jar, logo=logo, mini=mini)
|
|
56
|
+
der = psdz.keystore_der(lic)
|
|
57
|
+
_key, _cert, chain = pkcs12.load_key_and_certificates(der, password)
|
|
58
|
+
recips = [c for c in (chain or []) if recipient in c.subject.rfc4514_string()]
|
|
59
|
+
if not recips:
|
|
60
|
+
raise KeyError("recipient cert %r not found in sec_security.lic" % recipient)
|
|
61
|
+
n = recips[0].public_key().public_numbers().n
|
|
62
|
+
|
|
63
|
+
tmp = tempfile.mkdtemp(prefix="evocode-caf-")
|
|
64
|
+
try:
|
|
65
|
+
p12 = Path(tmp, "ks.p12"); p12.write_bytes(der)
|
|
66
|
+
cms = Path(tmp, "cms.der"); cms.write_bytes(cms_from_caf(caf_path))
|
|
67
|
+
rcert = Path(tmp, "recip.pem"); rcert.write_bytes(recips[0].public_bytes(Encoding.PEM))
|
|
68
|
+
dump = subprocess.run(["openssl", "pkcs12", "-legacy", "-in", str(p12),
|
|
69
|
+
"-passin", "pass:" + password.decode(), "-nodes"],
|
|
70
|
+
capture_output=True, text=True).stdout
|
|
71
|
+
rkey = None
|
|
72
|
+
for block in re.findall(r"-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----", dump, re.S):
|
|
73
|
+
if load_pem_private_key(block.encode(), password=None).public_key().public_numbers().n == n:
|
|
74
|
+
rkey = Path(tmp, "recip_key.pem"); rkey.write_text(block)
|
|
75
|
+
break
|
|
76
|
+
if rkey is None:
|
|
77
|
+
raise KeyError("recipient private key (%s) not found in keystore" % recipient)
|
|
78
|
+
out = subprocess.run(["openssl", "cms", "-decrypt", "-inform", "DER", "-in", str(cms),
|
|
79
|
+
"-inkey", str(rkey), "-recip", str(rcert)], capture_output=True)
|
|
80
|
+
if out.returncode != 0:
|
|
81
|
+
raise RuntimeError("openssl cms decrypt failed: %s" % out.stderr.decode()[:200])
|
|
82
|
+
payload = out.stdout
|
|
83
|
+
finally:
|
|
84
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
85
|
+
|
|
86
|
+
return gzip.decompress(payload) if payload[:2] == b"\x1f\x8b" else payload
|
evocode/catalog.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Per-CAFD bit catalogs: load one and decode a read NCD against it.
|
|
3
|
+
|
|
4
|
+
A catalog is one JSON file per CAFD (see catalogs/README.md): a list of parameters, each addressing
|
|
5
|
+
a coding bitfield by (datagroup, byte, mask, shift) with optional value labels. Decoding is purely
|
|
6
|
+
mechanical — the catalog is the data. The names/labels in a catalog are reconstructed descriptions
|
|
7
|
+
(firmware-traced or token-derived), authored and grown by the community, not shipped BMW data.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from . import srec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Param:
|
|
20
|
+
name: str
|
|
21
|
+
dg: int
|
|
22
|
+
byte: int
|
|
23
|
+
mask: int
|
|
24
|
+
shift: int
|
|
25
|
+
values: Dict[int, str] = field(default_factory=dict)
|
|
26
|
+
default: Optional[int] = None
|
|
27
|
+
token: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _int(x) -> int:
|
|
31
|
+
if isinstance(x, int):
|
|
32
|
+
return x
|
|
33
|
+
s = str(x).strip()
|
|
34
|
+
if s.endswith("b"): # binary mask like "10000000b"
|
|
35
|
+
return int(s[:-1], 2)
|
|
36
|
+
return int(s, 0) # "0x80", "128", "0b1000"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _shift_of(mask: int) -> int:
|
|
40
|
+
return (mask & -mask).bit_length() - 1 if mask else 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load(path: str) -> List[Param]:
|
|
44
|
+
doc = json.loads(open(path, encoding="utf-8").read())
|
|
45
|
+
records = doc["parameters"] if isinstance(doc, dict) else doc
|
|
46
|
+
out: List[Param] = []
|
|
47
|
+
for r in records:
|
|
48
|
+
mask = _int(r.get("mask", 0xFF))
|
|
49
|
+
shift = int(r["shift"]) if "shift" in r else _shift_of(mask)
|
|
50
|
+
dg = _int(r["datagroup"] if "datagroup" in r else r["dg"])
|
|
51
|
+
default = r.get("default")
|
|
52
|
+
out.append(Param(
|
|
53
|
+
name=r.get("name") or r.get("token") or r.get("coord") or "?",
|
|
54
|
+
dg=dg, byte=int(r["byte"]), mask=mask, shift=shift,
|
|
55
|
+
values={_int(k): v for k, v in (r.get("values") or {}).items()},
|
|
56
|
+
default=_int(default) if default not in (None, "") else None,
|
|
57
|
+
token=r.get("token"),
|
|
58
|
+
))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decode(mem: srec.Mem, params: List[Param]):
|
|
63
|
+
"""Yield (param, raw_value, label_or_None) for each parameter whose byte is present in mem."""
|
|
64
|
+
for p in params:
|
|
65
|
+
addr = (p.dg << 8) + p.byte
|
|
66
|
+
if addr not in mem:
|
|
67
|
+
continue
|
|
68
|
+
raw = (mem[addr] & p.mask) >> p.shift
|
|
69
|
+
yield p, raw, p.values.get(raw)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---- building a catalog skeleton from a decrypted CAFD XML ----
|
|
73
|
+
|
|
74
|
+
def _mask_int(s: str) -> int:
|
|
75
|
+
s = (s or "").strip()
|
|
76
|
+
return int(s[:-1], 2) if s.endswith("b") else int(s, 0)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_skeleton(cafd_xml: bytes) -> dict:
|
|
80
|
+
"""Turn a decrypted CAFD XML (see evocode.caf) into a catalog skeleton: every coding coordinate
|
|
81
|
+
with its datagroup, byte, mask, shift and factory default. Parameter NAMES and value labels are
|
|
82
|
+
left blank — BMW ships the CAFD with those stripped; they are a firmware-token / community overlay.
|
|
83
|
+
"""
|
|
84
|
+
import xml.etree.ElementTree as ET
|
|
85
|
+
|
|
86
|
+
def ln(t):
|
|
87
|
+
return t.rsplit("}", 1)[-1]
|
|
88
|
+
|
|
89
|
+
root = ET.fromstring(cafd_xml)
|
|
90
|
+
data = next(e for e in root.iter() if ln(e.tag) == "data")
|
|
91
|
+
params = []
|
|
92
|
+
for dg in data:
|
|
93
|
+
if ln(dg.tag) != "datagroup":
|
|
94
|
+
continue
|
|
95
|
+
addr = dg.attrib.get("address", "")
|
|
96
|
+
for f in dg:
|
|
97
|
+
if ln(f.tag) != "function":
|
|
98
|
+
continue
|
|
99
|
+
si = next((c for c in f if ln(c.tag) == "storageInfo"), None)
|
|
100
|
+
if si is None:
|
|
101
|
+
continue
|
|
102
|
+
get = lambda nm: next((c.text for c in si if ln(c.tag) == nm), None) # noqa: E731
|
|
103
|
+
start, mask = get("start"), get("mask")
|
|
104
|
+
if start is None or mask is None:
|
|
105
|
+
continue
|
|
106
|
+
m = _mask_int(mask)
|
|
107
|
+
rec = {
|
|
108
|
+
"name": "" if f.attrib.get("name", ".") in (".", "") else f.attrib["name"],
|
|
109
|
+
"datagroup": "0x%s" % addr.upper(),
|
|
110
|
+
"byte": int(start),
|
|
111
|
+
"mask": "0x%02X" % m,
|
|
112
|
+
"shift": (m & -m).bit_length() - 1 if m else 0,
|
|
113
|
+
}
|
|
114
|
+
dv = next((c for c in f if ln(c.tag) == "defVal"), None)
|
|
115
|
+
if dv is not None:
|
|
116
|
+
uv = next((c for c in dv if ln(c.tag) == "unitVal"), None)
|
|
117
|
+
if uv is not None and (uv.text or "").strip():
|
|
118
|
+
try:
|
|
119
|
+
rec["default"] = int(uv.text.strip(), 16)
|
|
120
|
+
except ValueError:
|
|
121
|
+
pass
|
|
122
|
+
params.append(rec)
|
|
123
|
+
return {"cafd": "", "version": "",
|
|
124
|
+
"note": "Skeleton from `evocode build-catalog`: coordinates/masks/defaults are factual, "
|
|
125
|
+
"from your CAFD. Parameter names/value-labels are blank — add them from the "
|
|
126
|
+
"firmware-token layer (community overlay); see catalogs/README.md.",
|
|
127
|
+
"parameters": params}
|