A
Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.
Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.
Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
3-digit MNC; 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); both sides now normalized
via SQLite datetime() so the window check is chronologically correct.
Add .gitignore for venv, caches, runtime config.yaml, and databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:56:31 -04:00
|
|
|
import json
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from server.profile_generator import (
|
A
2026-07-01 02:03:48 -04:00
|
|
|
build_lpa_activation_code,
|
A
Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.
Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.
Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
3-digit MNC; 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); both sides now normalized
via SQLite datetime() so the window check is chronologically correct.
Add .gitignore for venv, caches, runtime config.yaml, and databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:56:31 -04:00
|
|
|
create_esim_profile,
|
|
|
|
|
derive_opc,
|
A
2026-07-01 02:03:48 -04:00
|
|
|
enc_iccid,
|
|
|
|
|
enc_imsi,
|
A
Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.
Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.
Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
3-digit MNC; 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); both sides now normalized
via SQLite datetime() so the window check is chronologically correct.
Add .gitignore for venv, caches, runtime config.yaml, and databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:56:31 -04:00
|
|
|
generate_iccid,
|
|
|
|
|
generate_imsi,
|
|
|
|
|
generate_ki,
|
|
|
|
|
luhn_checksum,
|
A
2026-07-01 02:03:48 -04:00
|
|
|
swap_nibbles,
|
A
Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.
Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.
Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
3-digit MNC; 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); both sides now normalized
via SQLite datetime() so the window check is chronologically correct.
Add .gitignore for venv, caches, runtime config.yaml, and databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:56:31 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIMSI:
|
|
|
|
|
def test_length(self):
|
|
|
|
|
assert len(generate_imsi("302", "720")) == 15
|
|
|
|
|
|
|
|
|
|
def test_prefix(self):
|
|
|
|
|
imsi = generate_imsi("302", "720")
|
|
|
|
|
assert imsi.startswith("302720")
|
|
|
|
|
|
|
|
|
|
def test_digits_only(self):
|
|
|
|
|
assert generate_imsi("302", "720").isdigit()
|
|
|
|
|
|
|
|
|
|
def test_uniqueness(self):
|
|
|
|
|
imsis = {generate_imsi("302", "720") for _ in range(100)}
|
|
|
|
|
assert len(imsis) > 90
|
|
|
|
|
|
|
|
|
|
def test_different_mcc_mnc(self):
|
|
|
|
|
imsi = generate_imsi("001", "01")
|
|
|
|
|
assert imsi.startswith("00101")
|
|
|
|
|
assert len(imsi) == 15
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestKi:
|
|
|
|
|
def test_length(self):
|
|
|
|
|
assert len(generate_ki()) == 16
|
|
|
|
|
|
|
|
|
|
def test_randomness(self):
|
|
|
|
|
keys = {generate_ki() for _ in range(50)}
|
|
|
|
|
assert len(keys) == 50
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestICCID:
|
|
|
|
|
def test_length(self):
|
|
|
|
|
assert len(generate_iccid()) == 19
|
|
|
|
|
|
|
|
|
|
def test_digits_only(self):
|
|
|
|
|
assert generate_iccid().isdigit()
|
|
|
|
|
|
|
|
|
|
def test_luhn_valid(self):
|
|
|
|
|
iccid = generate_iccid()
|
|
|
|
|
assert luhn_checksum(iccid[:-1]) == int(iccid[-1])
|
|
|
|
|
|
|
|
|
|
def test_starts_with_prefix(self):
|
|
|
|
|
assert generate_iccid().startswith("8910")
|
|
|
|
|
|
|
|
|
|
def test_uniqueness(self):
|
|
|
|
|
iccids = {generate_iccid() for _ in range(50)}
|
|
|
|
|
assert len(iccids) == 50
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOPc:
|
|
|
|
|
KI_HEX = "000102030405060708090a0b0c0d0e0f"
|
|
|
|
|
|
|
|
|
|
def test_output_length(self):
|
|
|
|
|
ki = bytes.fromhex(self.KI_HEX)
|
|
|
|
|
assert len(derive_opc(ki, _OP)) == 16
|
|
|
|
|
|
|
|
|
|
def test_deterministic(self):
|
|
|
|
|
ki = bytes.fromhex(self.KI_HEX)
|
|
|
|
|
assert derive_opc(ki, _OP) == derive_opc(ki, _OP)
|
|
|
|
|
|
|
|
|
|
def test_different_from_inputs(self):
|
|
|
|
|
ki = bytes.fromhex(self.KI_HEX)
|
|
|
|
|
opc = derive_opc(ki, _OP)
|
|
|
|
|
assert opc != _OP
|
|
|
|
|
assert opc != ki
|
|
|
|
|
|
|
|
|
|
def test_different_ki_gives_different_opc(self):
|
|
|
|
|
ki1 = bytes.fromhex(self.KI_HEX)
|
|
|
|
|
ki2 = bytes.fromhex("0f0e0d0c0b0a09080706050403020100")
|
|
|
|
|
assert derive_opc(ki1, _OP) != derive_opc(ki2, _OP)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestProfile:
|
|
|
|
|
def _make_profile(self):
|
|
|
|
|
ki = generate_ki()
|
|
|
|
|
opc = derive_opc(ki, _OP)
|
|
|
|
|
imsi = generate_imsi("302", "720")
|
|
|
|
|
iccid = generate_iccid()
|
|
|
|
|
return create_esim_profile(imsi, ki, opc, iccid, "Test Profile", "Test Carrier")
|
|
|
|
|
|
|
|
|
|
def test_returns_bytes(self):
|
|
|
|
|
assert isinstance(self._make_profile(), bytes)
|
|
|
|
|
|
|
|
|
|
def test_non_empty(self):
|
|
|
|
|
assert len(self._make_profile()) > 0
|
|
|
|
|
|
|
|
|
|
def test_valid_json(self):
|
|
|
|
|
data = json.loads(self._make_profile())
|
|
|
|
|
assert "imsi" in data
|
|
|
|
|
assert "ki" in data
|
|
|
|
|
assert "opc" in data
|
|
|
|
|
assert "iccid" in data
|
|
|
|
|
|
|
|
|
|
def test_imsi_in_profile(self):
|
|
|
|
|
ki = generate_ki()
|
|
|
|
|
opc = derive_opc(ki, _OP)
|
|
|
|
|
imsi = generate_imsi("302", "720")
|
|
|
|
|
iccid = generate_iccid()
|
|
|
|
|
profile = create_esim_profile(imsi, ki, opc, iccid, "P", "C")
|
|
|
|
|
data = json.loads(profile)
|
|
|
|
|
assert data["imsi"] == imsi
|
|
|
|
|
assert data["iccid"] == iccid
|
A
2026-07-01 02:03:48 -04:00
|
|
|
|
|
|
|
|
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"
|