A
8672b0de6b
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>
93 lines
2.7 KiB
Python
93 lines
2.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 create_esim_profile(
|
|
imsi: str,
|
|
ki: bytes,
|
|
opc: bytes,
|
|
iccid: str,
|
|
profile_name: str,
|
|
carrier_name: str,
|
|
) -> bytes:
|
|
"""Create an eSIM profile.
|
|
|
|
Phase 1: returns a JSON-encoded mock profile suitable for end-to-end
|
|
testing. Phase 2 (Unit 06 upgrade): replace with real pySim SGP.22
|
|
profile generation.
|
|
"""
|
|
ki_hex = ki.hex() if isinstance(ki, bytes) else ki
|
|
opc_hex = opc.hex() if isinstance(opc, bytes) else opc
|
|
|
|
profile = {
|
|
"version": "1.0",
|
|
"type": "mock",
|
|
"iccid": iccid,
|
|
"imsi": imsi,
|
|
"ki": ki_hex,
|
|
"opc": opc_hex,
|
|
"profile_name": profile_name,
|
|
"carrier_name": carrier_name,
|
|
}
|
|
logger.debug(f"Created mock eSIM profile for IMSI {imsi}")
|
|
return json.dumps(profile, indent=2).encode("utf-8")
|