SAIP: add PE-USIM with EF.IMSI + full-sequence decoder

The SAIP profile now personalizes EF.IMSI via a PE-USIM referencing the standard
"created by default" USIM template (OID 2.23.143.1.2.4), so the package carries
ICCID + IMSI + Ki/OPc. IMSI content uses the existing TS 31.102 enc_imsi.

Add decode_saip_profile(): splits the concatenated ProfileElements by DER TLV
boundaries and decodes each (asn1tools decodes only one value at a time).

Tests: full-sequence decode ([header, usim, akaParameter, end]) and IMSI-in-USIM
round-trip; 93 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
A ashkan@beigi.net 2026-07-02 02:44:12 +00:00
parent b88fe4eed1
commit 470498bf07
4 changed files with 95 additions and 30 deletions

View File

@ -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

View File

@ -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$<smdp>$<matching-id>`).
- **`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).

View File

@ -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)]

View File

@ -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):