Push Tracker
ria-ran--sim-drop/server/saip_profile.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

116 lines
4.2 KiB
Python

"""Optional real SGP.22 SAIP profile-package generation.
Encodes a SIMalliance Interoperable Profile (SAIP) ``ProfileElement`` sequence
using the official ASN.1 schema (``PE_Definitions``) via ``asn1tools``. Unlike
``profile_generator.create_esim_profile`` (the always-available JSON
``profile-package-v2``), this produces real DER-encoded SGP.22 profile elements.
This module is optional and self-contained: it needs only ``asn1tools`` plus the
SAIP ASN.1 schema file. The schema ships with osmocom pySim; we locate it there
without importing pySim's Python package (which pulls in a card-reader stack).
Set ``SAIP_ASN_PATH`` to override the schema location. When neither is present,
``saip_available()`` returns False and callers fall back to profile-package-v2.
Scope: the encoded package currently carries the ProfileHeader (with ICCID) and
the Milenage AKA parameters (Ki/OPc) — the authentication-critical material.
Writing IMSI and other EFs requires USIM-template / GenericFileManagement PEs
with full File Control Parameters; that is the next layer and is not yet emitted.
"""
import functools
import importlib.util
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
_ASN_RELPATH = os.path.join("esim", "asn1", "saip", "PE_Definitions-3.3.1.asn")
def find_saip_asn() -> Optional[str]:
"""Locate the SAIP ASN.1 schema: SAIP_ASN_PATH override, else pySim's copy."""
override = os.environ.get("SAIP_ASN_PATH")
if override and os.path.exists(override):
return override
try:
spec = importlib.util.find_spec("pySim")
except (ImportError, ValueError):
return None
if spec and spec.submodule_search_locations:
for loc in spec.submodule_search_locations:
candidate = os.path.join(loc, _ASN_RELPATH)
if os.path.exists(candidate):
return candidate
return None
@functools.lru_cache(maxsize=1)
def _compiler():
"""Return a compiled asn1tools spec, or None if prerequisites are missing."""
try:
import asn1tools
except ImportError:
return None
path = find_saip_asn()
if not path:
return None
try:
return asn1tools.compile_files([path], codec="der")
except Exception as exc: # malformed/incompatible schema
logger.warning(f"Failed to compile SAIP ASN.1 schema at {path}: {exc}")
return None
def saip_available() -> bool:
"""True if a real SAIP profile can be generated in this environment."""
return _compiler() is not None
def create_saip_profile(
imsi: str,
ki: bytes,
opc: bytes,
iccid: str,
profile_name: str = "",
carrier_name: str = "",
) -> bytes:
"""Build a DER-encoded SAIP ProfileElement sequence (header + AKA + end).
``imsi`` is accepted for signature parity with ``create_esim_profile`` and is
reserved for the forthcoming USIM/EF layer; it is not yet encoded into an EF.
"""
asn = _compiler()
if asn is None:
raise RuntimeError(
"SAIP profile generation unavailable: requires asn1tools and the SAIP "
"ASN.1 schema (install osmocom pySim or set SAIP_ASN_PATH)"
)
# Imported here to avoid a hard dependency cycle at module import time.
from server.profile_generator import enc_iccid
ki_bytes = ki if isinstance(ki, bytes) else bytes.fromhex(ki)
opc_bytes = opc if isinstance(opc, bytes) else bytes.fromhex(opc)
header = ("header", {
"major-version": 3,
"minor-version": 3,
"profileType": (profile_name or "Emergency 5G")[:100],
"iccid": enc_iccid(iccid),
"eUICC-Mandatory-services": {"usim": None, "milenage": None},
"eUICC-Mandatory-GFSTEList": [],
})
aka = ("akaParameter", {
"aka-header": {"identification": 1},
"algoConfiguration": ("algoParameter", {
"algorithmID": 1, # Milenage
"algorithmOptions": b"\x00",
"key": ki_bytes,
"opc": opc_bytes,
}),
})
end = ("end", {"end-header": {"identification": 2}})
logger.debug(f"Encoding SAIP profile for IMSI {imsi} (ICCID {iccid})")
return b"".join(asn.encode("ProfileElement", pe) for pe in (header, aka, end))