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$<smdp>$<matching-id> 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 <noreply@anthropic.com>
This commit is contained in:
A ashkan@beigi.net 2026-07-01 06:03:48 +00:00
parent 8672b0de6b
commit 1ade8dfc2c
5 changed files with 170 additions and 8 deletions

46
CHANGELOG.md Normal file
View File

@ -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$<smdp>$<matching-id>`) 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.

View File

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

View File

@ -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)

View File

@ -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$<smdp-address>$<matching-id>``. 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")

View File

@ -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"