diff --git a/CHANGELOG.md b/CHANGELOG.md index e98ed07..ea0a7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Real SGP.22 SAIP profile generation** (`profile.format: saip`). - `server/saip_profile.py` encodes a DER `ProfileElement` sequence (ProfileHeader - with ICCID + Milenage AKA parameters carrying Ki/OPc) with `asn1tools` against - the official `PE_Definitions` ASN.1 schema — no pySim Python imports (the schema - is located from an installed pySim, or via `SAIP_ASN_PATH`). Self-contained and - optional: `saip_available()` gates it and the app falls back to `profile-v2` - when prerequisites are missing. Optional deps in `requirements-saip.txt`. - IMSI/other EFs (USIM-template PEs) are the documented next layer. + `server/saip_profile.py` encodes a DER `ProfileElement` sequence — ProfileHeader + (ICCID), PE-USIM personalizing EF.IMSI against the standard USIM template + (`2.23.143.1.2.4`), and Milenage AKA parameters (Ki/OPc) — with `asn1tools` + against the official `PE_Definitions` ASN.1 schema. No pySim Python imports (the + schema is located from an installed pySim, or via `SAIP_ASN_PATH`), so it avoids + the pyscard/smpp card-reader stack; the only runtime dep is `asn1tools`. + Self-contained and optional: `saip_available()` gates it and the app falls back + to `profile-package-v2` when prerequisites are missing. `decode_saip_profile()` + parses a package back into its ProfileElements. Optional deps in + `requirements-saip.txt`. A fully installable profile (remaining mandatory PEs + + card validation) is the documented next layer. - `pytest.ini` to silence asn1tools/pyparsing deprecation warnings. ### Security diff --git a/README.md b/README.md index 9fbbd99..439441c 100644 --- a/README.md +++ b/README.md @@ -102,16 +102,18 @@ Two formats are selectable via `profile.format`: the raw credentials plus **real 3GPP field encodings** (`EF_ICCID`, `EF_IMSI`, nibble-swapped BCD per 3GPP TS 31.102) and an SGP.22 **LPA activation code** (`LPA:1$$`). -- **`saip`** — a real DER-encoded **SGP.22 SAIP `ProfileElement` sequence** - (ProfileHeader with ICCID + Milenage AKA parameters carrying Ki/OPc), built - with `asn1tools` against the official `PE_Definitions` ASN.1 schema. Enable by - installing `requirements-saip.txt` (see that file for the schema source) and - setting `profile.format: saip`. If prerequisites are missing the server logs a - warning and falls back to `v2`. +- **`saip`** — a real DER-encoded **SGP.22 SAIP `ProfileElement` sequence**: + ProfileHeader (ICCID), PE-USIM personalizing EF.IMSI against the standard USIM + template, and Milenage AKA parameters (Ki/OPc). Built with `asn1tools` against + the official `PE_Definitions` ASN.1 schema. Enable by installing + `requirements-saip.txt` (see that file for the schema source) and setting + `profile.format: saip`. If prerequisites are missing the server logs a warning + and falls back to `v2`. `saip_profile.decode_saip_profile()` parses a package + back into its ProfileElements for inspection. -The SAIP package currently carries ICCID and the AKA credentials; emitting IMSI -and other EFs (USIM-template / GenericFileManagement PEs with File Control -Parameters) is the next layer. Note that consumer over-the-air install still +The SAIP package carries ICCID, IMSI, and the AKA credentials; a fully +installable profile additionally needs the remaining mandatory PEs (MF, PIN/PUK, +full EF set) and card-level validation. Consumer over-the-air install also requires an SM-DP+ / LPA; these packages target programmable SIMs and core-network sync. See [`CHANGELOG.md`](CHANGELOG.md). diff --git a/server/saip_profile.py b/server/saip_profile.py index 3b40b54..90b9202 100644 --- a/server/saip_profile.py +++ b/server/saip_profile.py @@ -26,6 +26,11 @@ logger = logging.getLogger(__name__) _ASN_RELPATH = os.path.join("esim", "asn1", "saip", "PE_Definitions-3.3.1.asn") +# Standard "created by default" USIM ADF template (TCA eUICC prefix 2.23.143.1 +# + templates.2 + usim.4). The eUICC creates ADF.USIM and its default EFs from +# this template; the profile only personalizes the EFs it fills (here EF.IMSI). +USIM_TEMPLATE_OID = "2.23.143.1.2.4" + def find_saip_asn() -> Optional[str]: """Locate the SAIP ASN.1 schema: SAIP_ASN_PATH override, else pySim's copy.""" @@ -74,10 +79,10 @@ def create_saip_profile( profile_name: str = "", carrier_name: str = "", ) -> bytes: - """Build a DER-encoded SAIP ProfileElement sequence (header + AKA + end). + """Build a DER-encoded SAIP ProfileElement sequence. - ``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. + Emits: ProfileHeader (with ICCID), PE-USIM (personalizing EF.IMSI against the + standard USIM template), Milenage PE-AKAParameter (Ki/OPc), and PE-End. """ asn = _compiler() if asn is None: @@ -87,7 +92,7 @@ def create_saip_profile( ) # Imported here to avoid a hard dependency cycle at module import time. - from server.profile_generator import enc_iccid + from server.profile_generator import enc_iccid, enc_imsi ki_bytes = ki if isinstance(ki, bytes) else bytes.fromhex(ki) opc_bytes = opc if isinstance(opc, bytes) else bytes.fromhex(opc) @@ -100,8 +105,17 @@ def create_saip_profile( "eUICC-Mandatory-services": {"usim": None, "milenage": None}, "eUICC-Mandatory-GFSTEList": [], }) + usim = ("usim", { + "usim-header": {"identification": 1}, + "templateID": USIM_TEMPLATE_OID, + "adf-usim": [], + "ef-imsi": [("fillFileContent", enc_imsi(imsi))], + # Other mandatory files are left to the template (empty = no override). + "ef-arr": [], "ef-ust": [], "ef-spn": [], "ef-est": [], + "ef-acc": [], "ef-ecc": [], + }) aka = ("akaParameter", { - "aka-header": {"identification": 1}, + "aka-header": {"identification": 2}, "algoConfiguration": ("algoParameter", { "algorithmID": 1, # Milenage "algorithmOptions": b"\x00", @@ -109,7 +123,42 @@ def create_saip_profile( "opc": opc_bytes, }), }) - end = ("end", {"end-header": {"identification": 2}}) + end = ("end", {"end-header": {"identification": 3}}) logger.debug(f"Encoding SAIP profile for IMSI {imsi} (ICCID {iccid})") - return b"".join(asn.encode("ProfileElement", pe) for pe in (header, aka, end)) + return b"".join(asn.encode("ProfileElement", pe) for pe in (header, usim, aka, end)) + + +def _split_der(data: bytes): + """Yield byte-range slices of consecutive top-level DER TLVs in ``data``.""" + i, n = 0, len(data) + while i < n: + start = i + first = data[i] + i += 1 + if first & 0x1F == 0x1F: # high-tag-number form: skip continuation octets + while i < n and data[i] & 0x80: + i += 1 + i += 1 + length_byte = data[i] + i += 1 + if length_byte & 0x80: # long-form length + num = length_byte & 0x7F + length = int.from_bytes(data[i:i + num], "big") + i += num + else: + length = length_byte + i += length + yield data[start:i] + + +def decode_saip_profile(data: bytes) -> list: + """Decode a SAIP ProfileElement sequence into a list of (choice, value). + + asn1tools decodes a single value, so we split the concatenated PEs by their + DER TLV boundaries and decode each one. + """ + asn = _compiler() + if asn is None: + raise RuntimeError("SAIP decoding unavailable (asn1tools/schema missing)") + return [asn.decode("ProfileElement", pe) for pe in _split_der(data)] diff --git a/tests/test_saip.py b/tests/test_saip.py index 802312c..d8228d7 100644 --- a/tests/test_saip.py +++ b/tests/test_saip.py @@ -2,7 +2,9 @@ import pytest import yaml from server.app import create_app -from server.profile_generator import derive_opc, enc_iccid, generate_iccid, generate_ki +from server.profile_generator import ( + derive_opc, enc_iccid, enc_imsi, generate_iccid, generate_ki, +) import server.saip_profile as saip _OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d") @@ -50,16 +52,24 @@ class TestSaipEncoding: assert pkg[0] == 0xA0 @saip_required - def test_header_roundtrips_iccid(self): + def test_full_sequence_decodes(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 + pes = saip.decode_saip_profile(pkg) + assert [choice for choice, _ in pes] == ["header", "usim", "akaParameter", "end"] + header = dict(pes)["header"] + assert header["iccid"] == enc_iccid(iccid) + assert header["major-version"] == 3 + + @saip_required + def test_imsi_embedded_in_usim(self): + imsi = "302720123456789" + pkg = saip.create_saip_profile(imsi, generate_ki(), b"\x11" * 16, generate_iccid()) + usim = dict(saip.decode_saip_profile(pkg))["usim"] + assert usim["templateID"] == saip.USIM_TEMPLATE_OID + assert usim["ef-imsi"] == [("fillFileContent", enc_imsi(imsi))] @saip_required def test_credentials_embedded(self):