FCA / Stellantis seed→key: the selector, and how to generate a provider#
Scope: this document explains how AlfaOBD decides which security-access
algorithm to run for a given module, reproduces the mapping we could recover
from the vendor artefacts, and shows how to turn your own copy of AlfaOBD
into a Python provider for pacifica-obd security --provider.
Shipped subset: the project also ships a built-in provider for the Pacifica
(RU) modules it already supports — the same recovery, reduced to those modules
and regenerated into src/pacifica_obd/fca_keys.py. See
fca-keys.md for what is included, what refuses, and how to
regenerate/verify it. Use --provider when you need a module outside that set.
Licence: nothing in this repository contains vendor constants or vendor code.
tools/extract_seedkey.py is our own tool; every constant that ends up in a
generated provider is read out of the files you point it at. Keep generated
providers local.
Confidence labels used below match docs/fca-modules.md:
[observed] = read directly out of a file/table (path + line given);
[verified] = reproduced byte-for-byte against the vendor binary;
[inferred] = reasoned from observed facts, stated as such.
1. Where the pieces live#
Three separate things have to line up before a key can be computed:
| Piece | Vendor artefact | What it is |
|---|---|---|
| The algorithm | dec/ad.cs — decompiled class ad, Dotfuscator original AlfaOBD_PC.AlfaOBDKeys |
851 Calculate* methods, one per (ECU, supplier, variant, algorithm, revision). 386 are tagged _PN (FCA PowerNet). No per-vehicle secret is involved. |
| The identification | DIDs F100 / F110 / F154 / F1A5, read at runtime |
fills AlfaOBD.cx (ECU_VARIANT_ID), .cy (ECU_VERSION_ID), .cv (ISOCode), .c2 (DeviceID) and the dispatcher's m_c0 |
| The selector | dec/af.cs — class af, original AlfaOBD_PC.AlfaOBD, method CalculateKey() at dec/af.cs:78624 |
a ~1,500-branch if/switch chain that picks one Calculate* method from the identification |
The readable field names come from the obfuscator map that ships next to
AlfaOBD.exe (Map.xml, entries of type AlfaOBD_PC.AlfaOBD):
c2 -> DeviceID cx -> ECU_VARIANT_ID
cy -> ECU_VERSION_ID cv -> ISOCode
That single line is the whole selector story: the module's device id and variant id pick the function.
2. How a module's identity maps to a CalculateKey* function#
CalculateKey() (dec/af.cs:78624) is a flat chain of branches:
if / else if ( (!this.m_ix && this.m_d.j == eEcutype.ECUTYPE_<name>) || this.m_ge == <n> )
{
switch (this.m_aj) // security level: 1, 3, 5, 7, 9, 61, 65 …
{
case 5:
if (global::af.c2 == <device id>) array = global::ad.<obf>(this.m_ak);
else if (global::af.cx == <variant>) array = global::ad.<obf>(this.m_ak);
…
with these inputs:
| field | meaning | set from |
|---|---|---|
m_d.j |
eEcutype of the selected module |
the ECU the user picked (ECUList.ecutype) |
af.c2 |
device id (a decimal string, e.g. "11872") |
ECUList.Device_ID, i.e. the ECU avatar the user picked; or resolved from F1A5 via the isocodes table (dec/af.cs:113447-113460) |
af.cx |
variant id | F110 array[3]<<8|array[4], or F100 array[4] (parser at dec/af.cs:124150-124600; m_c0 assignments at 124195 / 124561 / 124580) |
af.cy |
version id | same parser |
m_aj |
security level | the 0x27 sub-function being requested (S1/S3/S5/…) |
m_ak |
the seed bytes from the ECU | the 0x27 response |
m_ge |
a numeric branch override | set by the app's guided key-procedure screens, e.g. this.m_ge = 27; (dec/af.cs:156455), this.m_ge = 28; (161717) |
and the callee is the obfuscated two-letter name of a method in class ad,
whose real name is recovered from Map.xml (e.g. ad.mt →
CalculateKeyS1_IPC_PN_Sup00C6_Var49_AlgID0257_V01, dec/ad.cs:3524).
So the selector is not a table lookup keyed by a VIN or a part number: it
is (ECU type, security level, device id or variant id) → method.
3. The mapping we recovered [verified]#
tools/extract_seedkey.py --selector parses CalculateKey() and prints one row
per (branch, level, selector value) → function. Recovered from
scratchpad/dec/af.cs, it yields 274 rows across 39 branches.
Branch table (which discriminator each branch actually uses):
| branch (ECUTYPE) | m_ge |
rows | discriminator |
|---|---|---|---|
SOHC_V6, CHRYSLER_NGC4 |
0 | 11 | c2, cx, m_c0 |
(no ECUTYPE) |
2/7/15/20/23/39/51/57/66 | 30 | c2 (23 = RFH_PN — see below) |
WCM |
25 | 3 | level only |
BODY_CHRYSLER (BCM) |
27 | 1 | c2 |
IPC_PN |
28 | 39 | c2 |
ICS_PN |
29 | 22 | cx |
TIPM_CGW |
30 | 5 | m_c0 |
EDC17_FRMNT, MULTIJET_I4 |
31 | 12 | c2, cx |
| the seven diesel families | 32 | 16 | c2, cx |
LBSS_PN, RBSS_PN |
33 | 8 | c2 |
RADIO_NON_PN |
36 | 12 | m_c0 |
ABS_CHRYSLER |
40 | 9 | c2, m_c0 |
ABS_PN |
42 | 8 | cx |
AFLS_PN |
43 | 2 | cx |
ORC / ORC_PN |
44 / 45 | 7 | cx |
ASCM_PN |
46 | 8 | cx |
EDC17CF5_CAN |
47 | 1 | level only |
CVPM_PN |
48 | 4 | cx |
BPCM_PN |
49 | 16 | cx |
CONTINENTAL_DASH_EP |
56 | 2 | cx |
CSWM_PN |
58 | 4 | cx |
OCM_PN |
59 | 4 | cx |
ESM_PN |
60 | 11 | cx |
AMP_PN |
61 | 6 | cx, m_c0 |
ANC_PN |
62 | 8 | cx |
EPS_PN |
63 | 11 | cx, m_c0 |
APM_PN |
65 | 4 | cx |
HCP_PN |
67 | 10 | cx, m_c0 |
ADCM / ADCM_PN |
68 | 1 | cx |
3.1 The Pacifica (RU) rows#
device id = ECUList.Device_ID; the embedded copy of the same catalogue is
the 1652×17 table at dec/af.cs:4264 (m_lp), and ECUList in
alfaobd_plain.db agrees with it on 1181 of 1293 device ids [observed].
IPC_PN (branch at dec/af.cs:80584, m_ge == 28) — discriminator c2:
| device id | AlfaOBD's description | S1 algorithm | S5 algorithm |
|---|---|---|---|
| 11871 | (RU) PACIFICA, MY2017 PowerNet | CalculateKeyS1_IPC_PN_Sup0087_Var47_AlgID01A5_V01 |
CalculateKeyS5_IPC_PN_Sup0087_Var47_AlgID01A6_V02 |
| 11872 | (RU) PACIFICA, MY2020+ PowerNet | CalculateKeyS1_IPC_PN_Sup00C6_Var49_AlgID0257_V01 |
CalculateKeyS5_IPC_PN_Sup00C6_Var49_AlgID0258_V01 |
| 8160 | (LX)300 / (LD)Charger | CalculateKeyS1_IPC_PN_Sup00C6_Var40_V01 |
CalculateKeyS5_IPC_PN_Sup00C6_Var40_V01 |
| 8533 | (JC) Journey | CalculateKeyS1_IPC_PN_Sup0087_Var41_V01 |
… |
| 8511 | RAM 1500/2500/3500 | CalculateKeyS1_IPC_PN_Sup00C6_Var42_V01 |
… |
| 8164 | (ZD) Viper, Maserati | CalculateKeyS1_IPC_PN_Sup0030_Var43_V01 |
… |
| 8168, 13499 | (WD) Durango, (WK2) Grand Cherokee | CalculateKeyS1_IPC_PN_Sup0087_Var44_AlgID0118_V01 |
… |
| 8150 | (LA) Challenger | CalculateKeyS1_IPC_PN_Sup00C6_Var45_AlgID0171_V01 |
… |
| 7657 | (LX)300 / (LD)Charger | CalculateKeyS1_IPC_PN_Sup00C6_Var46_AlgID018B_V01 |
… |
| 18166 | (JL) Wrangler / (JT) Gladiator | CalculateKeyS1_IPC_PN_Sup0030_Var48_AlgID00B5_V01 |
… |
| 18167 | DT RAM 1500 | (shares 11872's pair) | … |
| 60182 | RAM D2/DD/DF/DJ/DP/DT | CalculateKeyS1_IPC_PN_Sup00C6_Var4B_AlgId040C_V03 |
… |
| 18168 | (WD) Durango / (WK2) Grand Cherokee | CalculateKeyS1_IPC_PN_Sup0087_Var4A_AlgID028B_V01 |
… |
RFH_PN (branch at dec/af.cs:79200) — this one is guarded by the device
id itself (c2 == 14039) rather than by ECUTYPE_RFH_PN; 14039 is "the
(RU)PACIFICA RFH". S1/S3/S5/S61 each have their own entry, e.g.
CalculateKeyS1_RFH_PN_Sup0040_Var45_AlgID0240_V01,
CalculateKeyS5_RFH_PN_Sup00C6_Var4A_AlgID02A9_V01.
BCM / BODY_CHRYSLER (m_ge == 27): c2 = 56077 → the
CalculateKeyS3_BCM_Sup00C6_Var0002_AlgID0300_V01 family; the S1/S5 variants
are in the same branch.
The full 170-row table for the Pacifica-relevant ECUs can be regenerated with:
python3 tools/extract_seedkey.py --source dec/ad.cs --map Map.xml \
--selector dec/af.cs --json | jq '.rows[]'
Row shape: {ecu_types, m_ge, level, selectors:[{field,value}], obf, func}.
4. Verification (how we know the port is right) [verified]#
We never trust a port by inspection. --emit-harness writes a small dotnet
project that compiles your ad.cs (with a stand-in for the two helpers the
decompiler left outside the class) and calls the original C#:
python3 tools/extract_seedkey.py --source dec/ad.cs --map Map.xml \
--emit-harness /tmp/gt
cd /tmp/gt && dotnet build -c Release
echo 'mt 12 34 56 78' | dotnet bin/Release/net10.0/gt.dll --batch
# mt BA32A3C0
tools/extract_seedkey.py --emit CalculateKeyS1_IPC_PN_Sup00C6_Var49_AlgID0257_V01
produces a Python provider that returns the same BA32A3C0 for seed
12 34 56 78.
Differential test run for this document (seeds 12345678, 00000000,
DEADBEEF):
| set | algorithms | seeds | agreements | silent mismatches | refused |
|---|---|---|---|---|---|
every _PN algorithm |
388 | 3 | 1155 | 0 | 3¹ |
random non-_PN sample |
120 | 3 | 318 | 0 | 12 |
all Calculate* algorithms |
849 | 3 | 2421 | 0 | 42¹ |
¹ most refusals are 8-byte-seed algorithms probed with a 4-byte one; re-run
them with an 8-byte seed and they match (…Sup0079_Var41_V02 seed 01..08 →
743392EE in both Python and C#). The rest are the RSA/legacy shapes listed
below.
"Refused" means the transpiler stops with a diagnostic instead of guessing —
mostly 8-byte-seed algorithms probed with a 4-byte seed, plus the FGA-era
families that build keys from character codes, the PCM/EDC17 RSA paths
(Math.Pow), and functions tested out of their normal context
(CalculateKeyCommon on its own divides by zero).
The transpiler is verified per algorithm, not in general. Run
--emit … --verify DIRbefore you trust a generated provider:
python3 tools/extract_seedkey.py --source dec/ad.cs --map Map.xml \
--emit CalculateKeyS1_IPC_PN_Sup00C6_Var49_AlgID0257_V01 \
-o /tmp/ipc.py --verify /tmp/gt --samples 8
# 34D7B71A python=8BC6690E c#=8BC6690E ok
# …
# verified: 8 seeds agree with the original C#
--verify exits non-zero and prints THIS PROVIDER IS WRONG if any seed
disagrees, and refuses to verify at all when the transpiler cannot translate the
function. Treat a provider you have not verified as a guess, not a key.
Known limitations that --verify will surface rather than hide:
Math.Pow/ RSA helpers and the FGA-era character-code families are refused.++/--inside&&,||or?:is refused (hoisting it would change when it runs).- Overload selection is type-directed; when two candidates score equally it records the ambiguity and picks the better-scoring one.
- Named arguments (
j(A_1: x, A_0: y), as ILSpy emits) are re-ordered to parameter order, which assumes side-effect-free arguments — true for every occurrence in this class. checked(…)overflow is compiled as wrap-around, likeunchecked.
5. What is still unknown (precisely)#
m_geassignment sites are only partially enumerated. For 22 of the 39 branches,m_geis the only key: the branch is reached when the user starts the app's guided key procedure, which setsm_geinline (e.g.this.m_ge = 27;at dec/af.cs:156455 — theSelectedIndex == 97list item;this.m_ge = 28;at 161717). TheeEcutypenames on those same branches are what let us name them; theSelectedIndex → ECUmapping comes from the app's UI list, not from a table we have read. Next step: for eachm_ge = Nassignment, read the surroundingif (…SelectedIndex == M)arm and resolveMagainst the ECU list source (af.b[...]string table / the DB'sECUList.ECUNAME). Spot-checked: 27 ↔BODY_CHRYSLER, 28 ↔IPC_PN, 23 ↔ RFH.- Branches keyed by
cx(variant id): we recovered the values the dispatcher compares against (e.g.ASCM_PNS1:cx=66,67,68), but not the FCA-level meaning of each variant id. To pin them down, cross-reference the(cx,cy) → variant nametables embedded inaf(m_lx,m_ly,m_lz,m_l0,m_l1,m_l2— dec/af.cs:54620, 54915, 55022, 55073, …). isocodesdoes not cover the Pacifica.11871/11872are absent from theisocodestable, so22 F1A5cannot auto-resolve the device id for our IPC; the selection comes from the ECU list (§3.1). Devices that are inisocodes(3569 rows) resolve asc2 = isocodes.device_id.- Transpiler coverage.
--listfinds 851Calculate*functions (849 distinct names). 807 of them were compared against the compiled original on 3 seeds each — 2421 agreements, 0 silent mismatches — and 42 were refused (see §4): mostly 8-byte-seed algorithms probed with a 4-byte seed, plus the FGA-era character-code families and thePCM/EDC17RSA paths (Math.Pow,strToCharCodeArray).--verifyre-checks any single algorithm you intend to use, and is the only thing that makes a provider trustworthy. - Nothing here is a statement about safety. Unlocking a module behind the
Security Gateway is a write-path operation; see
docs/chrysler-sgw.md.
One more trap worth knowing about: in the obfuscator map, different
original methods share one obfuscated name, distinguished only by signature —
five methods in class ad are called f (_copyInt, F1_AISIN,
fsak_calc_5, CalculateKeyIPC_ACC_GIULLIA_SeedKey2705, and the big-integer
helper whose original name is or). That is why the tool matches Map.xml
entries by signature, not just by name, and why --emit wants the original
CalculateKey… name rather than the two-letter one.
6. Step by step: a provider for your Pacifica#
6.1 Produce the inputs (once)#
From a Windows install of AlfaOBD 2.x (AlfaOBD.exe + Map.xml next to it):
# decompile; class `ad` holds every algorithm, class `af` the selector
ilspycmd -p -o dec --nested-directories AlfaOBD.exe
# you should now have dec/ad.cs (~11k lines) and dec/af.cs
No Windows box? The same information is in the Android APK, but the Java is obfuscated differently and this tool only reads the C# form.
6.2 Find your module#
python3 tools/extract_seedkey.py --source dec/ad.cs --map Map.xml --list --pn | less
python3 tools/extract_seedkey.py --source dec/ad.cs --map Map.xml \
--selector dec/af.cs --ecu IPC_PN
Read off your device id. For a 2021 Pacifica IPC that is 11871
(MY2017 PowerNet) or 11872 (MY2020+ PowerNet) — both are labelled
"(RU) PACIFICA" in the ECU list. Your van's 22 F110/22 F100 identify which
one, but the app also lets you pick it from the ECU list.
6.3 Generate the provider#
mkdir -p ~/.local/share/pacifica-obd/providers
python3 tools/extract_seedkey.py --source dec/ad.cs --map Map.xml \
--emit CalculateKeyS1_IPC_PN_Sup00C6_Var49_AlgID0257_V01 \
-o ~/.local/share/pacifica-obd/providers/ipc_pn_s1.py
The generated file is a plain Python module: a mechanical translation of that
one C# method plus everything it calls (usually 2–45 small functions), with all
the b.h("…", key) literals decoded and no vendor data of ours in it.
6.4 Check it against the original — do not skip this#
python3 tools/extract_seedkey.py --source dec/ad.cs --map Map.xml \
--emit CalculateKeyS1_IPC_PN_Sup00C6_Var49_AlgID0257_V01 \
-o ~/.local/share/pacifica-obd/providers/ipc_pn_s1.py \
--verify /tmp/gt --samples 8
--verify builds the harness if needed, runs the original C# for 8 random
seeds, diffs them against the provider and exits non-zero if anything
disagrees. A short single-shot version of the same check:
echo 'mt 12 34 56 78' | dotnet /tmp/gt/bin/Release/net10.0/gt.dll --batch # C#
python3 -c "import importlib.util as u; s=u.spec_from_file_location('p','$HOME/.local/share/pacifica-obd/providers/ipc_pn_s1.py'); m=u.module_from_spec(s); s.loader.exec_module(m); print(format(m.key_for(0x12345678),'08X'))"
Both must print BA32A3C0. Do this before you ever point the car at it.
6.5 Use it#
uv run pacifica-obd security --module 7E0 --level 1 \
--provider ~/.local/share/pacifica-obd/providers/ipc_pn_s1.py \
--unlock --confirm
--unlock --confirm is what makes the tool send 27 02 <key>; without it only
the seed is read. Unlocking is a write-path action — see docs/chrysler-sgw.md
— and the provider interface is documented, with a runnable example, in
docs/examples/seedkey_provider_example.py.
7. Appendix — the algorithm shapes [observed]#
All 851 methods are one of a handful of shapes. The two dominant ones:
CalculateKeyCommon1(ad.w8, dec/ad.cs:79) — two 32-bit constants, no tables. 384 functions are just
global::ad.m_r = <const>; global::ad.m_s = <const>; return w8(A_0);
The obfuscator map names the fields: m_r → _KEY_CONSTANT_1,
m_s → _KEY_CONSTANT_2.
CalculateKeyCommon(ad.w9, dec/ad.cs:69) — three big-integer constants (m_n→KEY_CONSTANT_1,m_o→KEY_CONSTANT_2,m_p→AND_CONSTANT), consumed as base-2¹⁵ limbs. 359 functions set them from decoded decimal/hex strings, e.g. dec/ad.cs
int a_ = 4;
global::ad.m_n = a(global::b.h("覹辻覽…", a_), 10); // -> "3379485635"
global::ad.m_o = a(global::b.h("覹蒻蚽…", a_), 10); // -> "3885590227"
global::ad.m_p = a(global::b.h("\udcb9…", a_), 16); // -> "ffffffff"
return w9(A_0);
The remaining 46 are inline, and 6 use CalculateKeyDiesel /
strToCharCodeArray (FGA-era families).
The string decoder (tools/extract_seedkey.py, and already in the RE tree as
tools/decstrings.py) is n = 1843161013 + key, per UTF-16 code unit
(dec/b.cs:503); every literal above is reproducible from the file.