From 1ade8dfc2ca3f7d615f79ec77faf3a6e825437c3 Mon Sep 17 00:00:00 2001 From: "ashkan@beigi.net" Date: Wed, 1 Jul 2026 06:03:48 +0000 Subject: [PATCH] Phase 2: real 3GPP profile encodings + changelog Upgrade create_esim_profile from a loose JSON mock to a structured profile-package-v2 that embeds real 3GPP field encodings: - enc_iccid: EF_ICCID nibble-swapped BCD, 10-byte 0xF-padded (TS 31.102 4.2.1) - enc_imsi: EF_IMSI length octet + parity nibble + nibble-swapped BCD (4.2.2) - build_lpa_activation_code: SGP.22 LPA:1$$ string Wire apn and smdp_address through config into the provisioning call; add smdp_address to config.yaml.example. Add CHANGELOG.md. Full SGP.22 SAIP/ASN.1 UPP generation via osmocom pySim remains a follow-up (not on PyPI; needs SM-DP+/LPA for OTA install) and is noted in the changelog. Tests: +9 (encoders, round-trips, activation code); 53 pass. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 46 ++++++++++++++++++++++ config.yaml.example | 1 + server/app.py | 5 ++- server/profile_generator.py | 70 +++++++++++++++++++++++++++++---- tests/test_profile_generator.py | 56 ++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ca8d616 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Phase-2 profile generation.** `create_esim_profile` now emits a structured + profile package with real 3GPP encodings instead of a loose JSON mock: + - `enc_iccid` — EF_ICCID encoding (nibble-swapped BCD, 10-byte, 0xF padded) + per 3GPP TS 31.102 §4.2.1. + - `enc_imsi` — EF_IMSI encoding (length octet + parity nibble + nibble-swapped + BCD) per 3GPP TS 31.102 §4.2.2. + - `build_lpa_activation_code` — SGP.22 LPA activation string + (`LPA:1$$`) so the profile carries a device-consumable + activation reference. +- `CHANGELOG.md`. + +### Changed +- Profile package `type` is now `profile-package-v2`; it embeds the raw + credentials plus the encoded EF byte values and the LPA activation code. + +### Fixed +- `generate_imsi` hardcoded a 10-digit MSIN, producing invalid 16-digit IMSIs + for a 3-digit MNC (e.g. MCC 302 / MNC 720). MSIN length is now + `15 - len(mcc) - len(mnc)`. +- `check_rate_limit` compared timestamps as strings across mismatched formats + (SQLite `CURRENT_TIMESTAMP` vs Python `isoformat`), making the rate-limit + window unreliable. Both sides are now normalized via SQLite `datetime()`. + +### Notes +- Full SGP.22 SIMalliance Interoperable Profile (SAIP / ASN.1 UPP) generation + via osmocom pySim remains the follow-up step; it needs an SM-DP+ / LPA to be + installed onto consumer devices over the air. The current package targets + programmable SIMs and core-network sync. + +## [0.1.0] — 2026-07-01 + +### Added +- Initial Phase-1 closed-loop eSIM provisioning stack: Flask API, SQLite + subscriber store, credential generator (IMSI/Ki/OPc/ICCID), Open5GS and + free5gc core adapters, captive portal, operational scripts, systemd units, + network configs, and a test suite. diff --git a/config.yaml.example b/config.yaml.example index 832422d..57fd29f 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -32,3 +32,4 @@ profile: carrier_name: "Ad-Hoc Network" profile_name: "Emergency 5G" apn: "internet" + smdp_address: "esim.local" # LPA activation-code SM-DP+ address diff --git a/server/app.py b/server/app.py index ff8ff44..4208269 100644 --- a/server/app.py +++ b/server/app.py @@ -65,6 +65,8 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask: profile_cfg = cfg["profile"] profile_name = profile_cfg["profile_name"] carrier_name = profile_cfg["carrier_name"] + apn = profile_cfg.get("apn", "internet") + smdp_address = profile_cfg.get("smdp_address", "esim.local") rl_cfg = cfg["rate_limiting"] window_hours = rl_cfg["window_hours"] @@ -113,7 +115,8 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask: opc_hex = bytes_to_hex(opc) profile_bytes = create_esim_profile( - imsi, ki, opc, iccid, profile_name, carrier_name + imsi, ki, opc, iccid, profile_name, carrier_name, + smdp_address=smdp_address, apn=apn, ) sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac) diff --git a/server/profile_generator.py b/server/profile_generator.py index 894e438..90fdcc9 100644 --- a/server/profile_generator.py +++ b/server/profile_generator.py @@ -61,6 +61,52 @@ def derive_opc(ki: bytes, op: bytes) -> bytes: return bytes(a ^ b for a, b in zip(encrypted_op, op)) +def swap_nibbles(s: str) -> str: + """Swap the nibbles of every byte in a hex string. + + ``"1234"`` -> ``"2143"``. Used for the BCD-with-swapped-nibbles layout + that 3GPP uses for ICCID and IMSI elementary files. + """ + if len(s) % 2: + raise ValueError(f"hex string must have even length: {s!r}") + return "".join(s[i + 1] + s[i] for i in range(0, len(s), 2)) + + +def enc_iccid(iccid: str) -> bytes: + """Encode an ICCID as EF_ICCID bytes (3GPP TS 31.102 §4.2.1). + + Digits are BCD packed with swapped nibbles and right-padded to 10 bytes + (20 nibbles) with 0xF. + """ + if not iccid.isdigit(): + raise ValueError(f"ICCID must be digits only: {iccid!r}") + digits = iccid.ljust(20, "f") + return bytes.fromhex(swap_nibbles(digits)) + + +def enc_imsi(imsi: str) -> bytes: + """Encode an IMSI as EF_IMSI bytes (3GPP TS 31.102 §4.2.2). + + Layout: length octet, then a parity nibble ((odd<<3)|1) prepended to the + IMSI digits, the whole run nibble-swapped. + """ + if not imsi.isdigit(): + raise ValueError(f"IMSI must be digits only: {imsi!r}") + odd = len(imsi) & 1 + value = swap_nibbles(f"{(odd << 3) | 1:01x}{imsi}") + length = len(value) // 2 + return bytes([length]) + bytes.fromhex(value) + + +def build_lpa_activation_code(iccid: str, smdp_address: str) -> str: + """Build an SGP.22 LPA activation code string. + + Format: ``LPA:1$$``. The ICCID is used as the + matching id so the profile is self-referencing for this ad-hoc deployment. + """ + return f"LPA:1${smdp_address}${iccid}" + + def create_esim_profile( imsi: str, ki: bytes, @@ -68,25 +114,35 @@ def create_esim_profile( iccid: str, profile_name: str, carrier_name: str, + smdp_address: str = "esim.local", + apn: str = "internet", ) -> bytes: - """Create an eSIM profile. + """Create an eSIM profile package (``profile-package-v2``). - Phase 1: returns a JSON-encoded mock profile suitable for end-to-end - testing. Phase 2 (Unit 06 upgrade): replace with real pySim SGP.22 - profile generation. + Emits a structured package embedding the raw credentials, the real 3GPP + EF_ICCID / EF_IMSI byte encodings, and an SGP.22 LPA activation code. + + Full SGP.22 SIMalliance Interoperable Profile (ASN.1 UPP) generation via + osmocom pySim remains the follow-up step; see CHANGELOG. """ ki_hex = ki.hex() if isinstance(ki, bytes) else ki opc_hex = opc.hex() if isinstance(opc, bytes) else opc profile = { - "version": "1.0", - "type": "mock", + "version": "2.0", + "type": "profile-package-v2", "iccid": iccid, "imsi": imsi, "ki": ki_hex, "opc": opc_hex, "profile_name": profile_name, "carrier_name": carrier_name, + "apn": apn, + "activation_code": build_lpa_activation_code(iccid, smdp_address), + "ef": { + "iccid": enc_iccid(iccid).hex(), + "imsi": enc_imsi(imsi).hex(), + }, } - logger.debug(f"Created mock eSIM profile for IMSI {imsi}") + logger.debug(f"Created profile-package-v2 for IMSI {imsi}") return json.dumps(profile, indent=2).encode("utf-8") diff --git a/tests/test_profile_generator.py b/tests/test_profile_generator.py index 9abdc14..0b68bdc 100644 --- a/tests/test_profile_generator.py +++ b/tests/test_profile_generator.py @@ -2,12 +2,16 @@ import json import pytest from server.profile_generator import ( + build_lpa_activation_code, create_esim_profile, derive_opc, + enc_iccid, + enc_imsi, generate_iccid, generate_imsi, generate_ki, luhn_checksum, + swap_nibbles, ) _OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d") @@ -115,3 +119,55 @@ class TestProfile: data = json.loads(profile) assert data["imsi"] == imsi assert data["iccid"] == iccid + + def test_v2_type_and_activation_code(self): + ki = generate_ki() + opc = derive_opc(ki, _OP) + data = json.loads( + create_esim_profile(generate_imsi("302", "720"), ki, opc, generate_iccid(), "P", "C") + ) + assert data["type"] == "profile-package-v2" + assert data["activation_code"].startswith("LPA:1$") + assert "iccid" in data["ef"] and "imsi" in data["ef"] + + +def _dec_nibble_swapped_digits(b: bytes) -> str: + return swap_nibbles(b.hex()) + + +class TestEncoding: + def test_swap_nibbles(self): + assert swap_nibbles("1234") == "2143" + assert swap_nibbles("89") == "98" + + def test_swap_nibbles_rejects_odd(self): + with pytest.raises(ValueError): + swap_nibbles("123") + + def test_enc_iccid_length_10_bytes(self): + assert len(enc_iccid("8910302720000000001")) == 10 + + def test_enc_iccid_nibble_swap(self): + # First two ICCID digits "89" -> byte 0x98 + assert enc_iccid("8910302720000000001")[0] == 0x98 + + def test_enc_iccid_roundtrip(self): + iccid = generate_iccid() + decoded = _dec_nibble_swapped_digits(enc_iccid(iccid)).rstrip("f") + assert decoded == iccid + + def test_enc_imsi_length_octet(self): + # 15-digit IMSI + 1 parity nibble = 16 nibbles = 8 value bytes + out = enc_imsi("302720974348443") + assert out[0] == 8 + assert len(out) == 9 # length octet + 8 value bytes + + def test_enc_imsi_roundtrip(self): + imsi = generate_imsi("302", "720") + value = _dec_nibble_swapped_digits(enc_imsi(imsi)[1:]) + # first nibble is the parity indicator; the rest is the IMSI + assert value[1:] == imsi + + def test_lpa_activation_code_format(self): + code = build_lpa_activation_code("8910302720000000001", "smdp.example") + assert code == "LPA:1$smdp.example$8910302720000000001"