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
|
||||
- **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
|
||||
|
|
|
|||
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`,
|
||||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user