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:
parent
8672b0de6b
commit
1ade8dfc2c
46
CHANGELOG.md
Normal file
46
CHANGELOG.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user