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