import json import secrets import logging from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend logger = logging.getLogger(__name__) def generate_imsi(mcc: str, mnc: str) -> str: """Generate a random 15-digit IMSI: MCC + MNC + MSIN. IMSI is always 15 digits. MCC is 3 digits; MNC is 2 or 3 digits, so the MSIN fills the remaining 9 or 10 digits. """ msin_len = 15 - len(mcc) - len(mnc) if msin_len <= 0: raise ValueError(f"MCC/MNC too long for a 15-digit IMSI: {mcc!r}/{mnc!r}") msin = str(secrets.randbelow(10**msin_len)).zfill(msin_len) imsi = mcc + mnc + msin assert len(imsi) == 15, f"IMSI length error: {imsi!r}" return imsi def generate_ki() -> bytes: """Generate a 16-byte cryptographically random authentication key.""" return secrets.token_bytes(16) def luhn_checksum(number: str) -> int: """Compute the Luhn check digit for a string of digits.""" digits = [int(d) for d in number] odd_digits = digits[-1::-2] even_digits = digits[-2::-2] total = sum(odd_digits) for d in even_digits: total += sum(divmod(d * 2, 10)) return (10 - (total % 10)) % 10 def generate_iccid() -> str: """Generate a 19-digit ICCID with a valid Luhn check digit. Format: 8910 (telecom prefix) + 10 random digits + 5 more + Luhn digit = 19 total. """ prefix = "8910" body = prefix + str(secrets.randbelow(10**14)).zfill(14) # 18 digits check = luhn_checksum(body) return body + str(check) def derive_opc(ki: bytes, op: bytes) -> bytes: """Derive OPc from Ki and OP using the Milenage algorithm. OPc = AES_Ki(OP) XOR OP """ cipher = Cipher(algorithms.AES(ki), modes.ECB(), backend=default_backend()) enc = cipher.encryptor() encrypted_op = enc.update(op) + enc.finalize() 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$$``. 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, opc: bytes, iccid: str, profile_name: str, carrier_name: str, smdp_address: str = "esim.local", apn: str = "internet", ) -> bytes: """Create an eSIM profile package (``profile-package-v2``). 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": "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 profile-package-v2 for IMSI {imsi}") return json.dumps(profile, indent=2).encode("utf-8")