A
1ade8dfc2c
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>
149 lines
4.7 KiB
Python
149 lines
4.7 KiB
Python
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$<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,
|
|
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")
|