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:
parent
b88fe4eed1
commit
470498bf07
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -9,13 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Real SGP.22 SAIP profile generation** (`profile.format: saip`).
|
- **Real SGP.22 SAIP profile generation** (`profile.format: saip`).
|
||||||
`server/saip_profile.py` encodes a DER `ProfileElement` sequence (ProfileHeader
|
`server/saip_profile.py` encodes a DER `ProfileElement` sequence — ProfileHeader
|
||||||
with ICCID + Milenage AKA parameters carrying Ki/OPc) with `asn1tools` against
|
(ICCID), PE-USIM personalizing EF.IMSI against the standard USIM template
|
||||||
the official `PE_Definitions` ASN.1 schema — no pySim Python imports (the schema
|
(`2.23.143.1.2.4`), and Milenage AKA parameters (Ki/OPc) — with `asn1tools`
|
||||||
is located from an installed pySim, or via `SAIP_ASN_PATH`). Self-contained and
|
against the official `PE_Definitions` ASN.1 schema. No pySim Python imports (the
|
||||||
optional: `saip_available()` gates it and the app falls back to `profile-v2`
|
schema is located from an installed pySim, or via `SAIP_ASN_PATH`), so it avoids
|
||||||
when prerequisites are missing. Optional deps in `requirements-saip.txt`.
|
the pyscard/smpp card-reader stack; the only runtime dep is `asn1tools`.
|
||||||
IMSI/other EFs (USIM-template PEs) are the documented next layer.
|
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.
|
- `pytest.ini` to silence asn1tools/pyparsing deprecation warnings.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
|
||||||
20
README.md
20
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`,
|
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**
|
nibble-swapped BCD per 3GPP TS 31.102) and an SGP.22 **LPA activation code**
|
||||||
(`LPA:1$<smdp>$<matching-id>`).
|
(`LPA:1$<smdp>$<matching-id>`).
|
||||||
- **`saip`** — a real DER-encoded **SGP.22 SAIP `ProfileElement` sequence**
|
- **`saip`** — a real DER-encoded **SGP.22 SAIP `ProfileElement` sequence**:
|
||||||
(ProfileHeader with ICCID + Milenage AKA parameters carrying Ki/OPc), built
|
ProfileHeader (ICCID), PE-USIM personalizing EF.IMSI against the standard USIM
|
||||||
with `asn1tools` against the official `PE_Definitions` ASN.1 schema. Enable by
|
template, and Milenage AKA parameters (Ki/OPc). Built with `asn1tools` against
|
||||||
installing `requirements-saip.txt` (see that file for the schema source) and
|
the official `PE_Definitions` ASN.1 schema. Enable by installing
|
||||||
setting `profile.format: saip`. If prerequisites are missing the server logs a
|
`requirements-saip.txt` (see that file for the schema source) and setting
|
||||||
warning and falls back to `v2`.
|
`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
|
The SAIP package carries ICCID, IMSI, and the AKA credentials; a fully
|
||||||
and other EFs (USIM-template / GenericFileManagement PEs with File Control
|
installable profile additionally needs the remaining mandatory PEs (MF, PIN/PUK,
|
||||||
Parameters) is the next layer. Note that consumer over-the-air install still
|
full EF set) and card-level validation. Consumer over-the-air install also
|
||||||
requires an SM-DP+ / LPA; these packages target programmable SIMs and
|
requires an SM-DP+ / LPA; these packages target programmable SIMs and
|
||||||
core-network sync. See [`CHANGELOG.md`](CHANGELOG.md).
|
core-network sync. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ASN_RELPATH = os.path.join("esim", "asn1", "saip", "PE_Definitions-3.3.1.asn")
|
_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]:
|
def find_saip_asn() -> Optional[str]:
|
||||||
"""Locate the SAIP ASN.1 schema: SAIP_ASN_PATH override, else pySim's copy."""
|
"""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 = "",
|
profile_name: str = "",
|
||||||
carrier_name: str = "",
|
carrier_name: str = "",
|
||||||
) -> bytes:
|
) -> 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
|
Emits: ProfileHeader (with ICCID), PE-USIM (personalizing EF.IMSI against the
|
||||||
reserved for the forthcoming USIM/EF layer; it is not yet encoded into an EF.
|
standard USIM template), Milenage PE-AKAParameter (Ki/OPc), and PE-End.
|
||||||
"""
|
"""
|
||||||
asn = _compiler()
|
asn = _compiler()
|
||||||
if asn is None:
|
if asn is None:
|
||||||
|
|
@ -87,7 +92,7 @@ def create_saip_profile(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Imported here to avoid a hard dependency cycle at module import time.
|
# 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)
|
ki_bytes = ki if isinstance(ki, bytes) else bytes.fromhex(ki)
|
||||||
opc_bytes = opc if isinstance(opc, bytes) else bytes.fromhex(opc)
|
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-services": {"usim": None, "milenage": None},
|
||||||
"eUICC-Mandatory-GFSTEList": [],
|
"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 = ("akaParameter", {
|
||||||
"aka-header": {"identification": 1},
|
"aka-header": {"identification": 2},
|
||||||
"algoConfiguration": ("algoParameter", {
|
"algoConfiguration": ("algoParameter", {
|
||||||
"algorithmID": 1, # Milenage
|
"algorithmID": 1, # Milenage
|
||||||
"algorithmOptions": b"\x00",
|
"algorithmOptions": b"\x00",
|
||||||
|
|
@ -109,7 +123,42 @@ def create_saip_profile(
|
||||||
"opc": opc_bytes,
|
"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})")
|
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)]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from server.app import create_app
|
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
|
import server.saip_profile as saip
|
||||||
|
|
||||||
_OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
|
_OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
|
||||||
|
|
@ -50,16 +52,24 @@ class TestSaipEncoding:
|
||||||
assert pkg[0] == 0xA0
|
assert pkg[0] == 0xA0
|
||||||
|
|
||||||
@saip_required
|
@saip_required
|
||||||
def test_header_roundtrips_iccid(self):
|
def test_full_sequence_decodes(self):
|
||||||
ki = generate_ki()
|
ki = generate_ki()
|
||||||
opc = derive_opc(ki, _OP)
|
opc = derive_opc(ki, _OP)
|
||||||
iccid = generate_iccid()
|
iccid = generate_iccid()
|
||||||
pkg = saip.create_saip_profile("302720000000001", ki, opc, iccid, "P", "C")
|
pkg = saip.create_saip_profile("302720000000001", ki, opc, iccid, "P", "C")
|
||||||
asn = saip._compiler()
|
pes = saip.decode_saip_profile(pkg)
|
||||||
choice, value = asn.decode("ProfileElement", pkg)
|
assert [choice for choice, _ in pes] == ["header", "usim", "akaParameter", "end"]
|
||||||
assert choice == "header"
|
header = dict(pes)["header"]
|
||||||
assert value["iccid"] == enc_iccid(iccid)
|
assert header["iccid"] == enc_iccid(iccid)
|
||||||
assert value["major-version"] == 3
|
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
|
@saip_required
|
||||||
def test_credentials_embedded(self):
|
def test_credentials_embedded(self):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user