Push Tracker
ria-ran--sim-drop/tests/test_saip.py
A ashkan@beigi.net b88fe4eed1 Add real SGP.22 SAIP profile generation (optional)
New server/saip_profile.py encodes a DER ProfileElement sequence (ProfileHeader
with ICCID + Milenage AKA parameters carrying Ki/OPc) using asn1tools against
the official PE_Definitions SAIP ASN.1 schema. It locates the schema from an
installed osmocom pySim (or SAIP_ASN_PATH) via find_spec WITHOUT importing
pySim's Python package, so it avoids the pyscard/smpp card-reader stack — the
only runtime dep is asn1tools.

Selectable via profile.format ("v2" default | "saip"); the app falls back to
profile-package-v2 with a warning when SAIP prerequisites are absent. IMSI and
other EFs (USIM-template PEs) are the documented next layer.

Add requirements-saip.txt, pytest.ini (silence asn1tools warnings), README and
config updates. Tests: +6 (encode/round-trip/credentials, app emit + fallback);
92 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 02:35:54 +00:00

92 lines
3.7 KiB
Python

import pytest
import yaml
from server.app import create_app
from server.profile_generator import derive_opc, enc_iccid, generate_iccid, generate_ki
import server.saip_profile as saip
_OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
saip_required = pytest.mark.skipif(
not saip.saip_available(),
reason="SAIP unavailable (asn1tools + SAIP ASN.1 schema not installed)",
)
def _write_config(tmp_path, profile_format="v2"):
cfg = {
"network": {"mcc": "302", "mnc": "720", "plmn": "302720",
"op_key": "63bfa50ee6523365ff14c1f45f88737d"},
"wifi": {"ssid": "x", "channel": 6, "ip": "192.168.4.1",
"subnet": "192.168.4.0/24", "dhcp_range": "a,b"},
"server": {"host": "0.0.0.0", "port": 5000, "debug": False},
"database": {"path": str(tmp_path / "subscribers.db")},
"rate_limiting": {"max_profiles_per_mac": 1, "window_hours": 1},
"core": {"type": "none"},
"profile": {"carrier_name": "Ad-Hoc", "profile_name": "Emergency 5G",
"apn": "internet", "smdp_address": "esim.local",
"format": profile_format},
}
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump(cfg))
return str(path)
class TestSaipEncoding:
def test_unavailable_raises(self, monkeypatch):
monkeypatch.setattr(saip, "_compiler", lambda: None)
with pytest.raises(RuntimeError, match="unavailable"):
saip.create_saip_profile("302720000000001", generate_ki(),
b"\x00" * 16, generate_iccid())
@saip_required
def test_produces_der_bytes(self):
ki = generate_ki()
opc = derive_opc(ki, _OP)
iccid = generate_iccid()
pkg = saip.create_saip_profile("302720000000001", ki, opc, iccid, "Emergency 5G", "C")
assert isinstance(pkg, bytes) and len(pkg) > 0
# First byte is the context tag for the 'header' CHOICE alternative.
assert pkg[0] == 0xA0
@saip_required
def test_header_roundtrips_iccid(self):
ki = generate_ki()
opc = derive_opc(ki, _OP)
iccid = generate_iccid()
pkg = saip.create_saip_profile("302720000000001", ki, opc, iccid, "P", "C")
asn = saip._compiler()
choice, value = asn.decode("ProfileElement", pkg)
assert choice == "header"
assert value["iccid"] == enc_iccid(iccid)
assert value["major-version"] == 3
@saip_required
def test_credentials_embedded(self):
ki = generate_ki()
opc = derive_opc(ki, _OP)
pkg = saip.create_saip_profile("302720000000001", ki, opc, generate_iccid(), "P", "C")
assert ki in pkg # raw Ki bytes present in the AKA parameter
assert opc in pkg
class TestSaipAppIntegration:
@saip_required
def test_provision_emits_saip_when_configured(self, tmp_path):
app = create_app(_write_config(tmp_path, profile_format="saip"))
client = app.test_client()
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:00:11:22"})
assert r.status_code == 200
profile = client.get(r.get_json()["profile_url"]).data
assert profile[0] == 0xA0 # DER, not JSON
def test_falls_back_to_v2_when_saip_unavailable(self, tmp_path, monkeypatch):
import server.app as appmod
monkeypatch.setattr(appmod, "saip_available", lambda: False)
app = create_app(_write_config(tmp_path, profile_format="saip"))
client = app.test_client()
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:00:11:33"})
assert r.status_code == 200
profile = client.get(r.get_json()["profile_url"]).data
assert profile.lstrip()[:1] == b"{" # JSON profile-package-v2