GSMA TS.43 v13.0 Test Entitlement Configuration Server (ECS)
Project description
TS.43 Test ECS
Open-source GSMA TS.43 v13.0 Entitlement Configuration Server (ECS) for testing client devices, Android first. A single-process Python server — FastAPI + SQLite + pure-Python EAP-AKA/AKA′ — with no Docker, no FreeRADIUS, and no Keycloak. Point a phone (or the bundled protocol-faithful client) at it and exercise real entitlement flows end-to-end: VoLTE, VoWiFi, eSIM ODSA, satellite, DataBoost, carrier billing, phone-number verification.
Status: v1.3.0 — published to PyPI as ts43-test-ecs. 914 tests,
TS.43 §-by-§ traceability generated from test markers
(see docs/compliance_matrix.md), plus an
AOSP-differential conformance harness with a deterministic replay corpus.
Contents
- Why this exists
- Supported AppIDs
- Quick start
- Making your first entitlement request
- Authentication methods
- HTTP surface
- Test-control (admin) API
- Scenario presets
- Configuration
- Testing with a real Android device
- Architecture
- Development
- Protocol fidelity notes
- Key documentation
- License
Why this exists
GSMA TS.43 is the protocol Android and iOS use to ask a carrier "is this SIM entitled to VoWiFi / eSIM transfer / satellite messaging?". Commercial ECS deployments are closed, and there was no public server implementation to test clients against. This project fills that gap for lab use:
- Real authentication — full EAP-AKA/AKA′ over the TS.43 HTTP relay, driven by a pure-Python Milenage implementation. Works with programmable SIMs (known Ki/OPc) and handles real-SIM SQN resynchronization (AUTS).
- Real documents — byte-stable OMA WAP provisioning XML and JSON responses, golden-file tested against what the AOSP client actually parses.
- A test-control plane — seed subscribers, flip entitlement parameters, inject faults, apply scenario presets, inspect every request the device made.
Supported AppIDs
All 13 AppIDs known to the AOSP service_entitlement library, plus the
spec-only ap2015:
| AppID | Service | TS.43 § |
|---|---|---|
| ap2003 | VoLTE / VoCel entitlement | 3 |
| ap2004 | VoWiFi entitlement (T&C / address flows) | 4 |
| ap2005 | SMSoIP entitlement | 5 |
| ap2006 | ODSA companion device (eSIM) | 6–8 |
| ap2009 | ODSA primary device (eSIM) | 6–8 |
| ap2010 | Data plan information | 9 |
| ap2011 | ODSA server-initiated requests | 10 |
| ap2012 | DataBoost / premium network capability | 11 |
| ap2013 | Direct carrier billing | 12 |
| ap2014 | App-Server authentication / phone-number verification | 13 |
| ap2015 | Server-to-server operator tokens | 14 |
| ap2016 | Satellite entitlement + SatMode Config (v13) | 15 |
| ap2017 | ODSA cross-platform transfer | 6–8 |
Unknown AppIDs fall back to EntitlementStatus=0 rather than erroring, matching
commercial server behavior.
Quick start
The CI-tested, pip-install-based walkthrough lives in the
Quickstart Guide — from pip install ts43-test-ecs to a
first simulated-client entitlement fetch.
To run from a source checkout instead (requires Python ≥ 3.11):
git clone https://github.com/RamboXWang/ts43-test-ecs.git
cd ts43-test-ecs
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
python scripts/generate_certs.py # local CA + ECDSA server cert + JWT keypair
python -m ts43ecs # serves https://127.0.0.1:8443
curl -sk https://127.0.0.1:8443/health
python scripts/generate_certs.py writes a local CA (certs/ca.crt), a server
certificate with lab-friendly SANs, and the ES256 keypair used to sign auth
tokens. The server binds loopback by default and refuses to bind non-loopback
addresses with missing or example credentials.
Making your first entitlement request
Seed a subscriber (3GPP test-SIM credentials), then run the full EAP-AKA
handshake with the bundled EcsClient — it implements the device side of the
protocol exactly:
export ECS_ADMIN_API_KEY=my-lab-key # set before starting the server
curl -sk -X POST https://127.0.0.1:8443/admin/api/subscribers \
-H "X-Admin-Key: my-lab-key" -H "Content-Type: application/json" \
-d '{"imsi":"001010000000001",
"ki":"000102030405060708090a0b0c0d0e0f",
"opc":"00000000000000000000000000000000",
"msisdn":"+15551234567"}'
import asyncio, httpx
from ts43ecs.tools.ecs_client.client import EcsClient
async def main():
async with httpx.AsyncClient(base_url="https://127.0.0.1:8443",
verify="certs/ca.crt") as http:
resp = await EcsClient(client=http).authenticate(
imsi="001010000000001",
ki="000102030405060708090a0b0c0d0e0f",
opc="00000000000000000000000000000000",
app="ap2004",
)
print(resp.document) # VERS + TOKEN + ap2004 APPLICATION
print(resp.document.token) # fast re-auth token for follow-ups
asyncio.run(main())
The document carries the ap2004 characteristic (EntitlementStatus,
TC_Status, AddrStatus, …) plus a fast re-auth token. Subsequent requests
skip EAP entirely — the CLI covers that path:
python -m ts43ecs.cli client \
--url https://127.0.0.1:8443 --ca certs/ca.crt \
--imsi 001010000000001 --app ap2004 --token "<token from above>"
CLI flags: --app (repeatable), --json (JSON instead of XML), --post,
--insecure, and --ki/--opc to run the full EAP-AKA handshake from the
command line instead of a token. With neither a token nor Ki/OPc the CLI sends
an unauthenticated request — useful for observing the server's EAP challenge /
511 behavior.
Authentication methods
The server implements every TS.43 §2.8 authentication path:
| Method | § | Notes |
|---|---|---|
| EAP-AKA / EAP-AKA′ | 2.8.1 | HTTP-relay (JSON body over HTTP 200), Milenage, max 3 attempts, AUTS resync |
| Fast re-auth token | 2.8.5 | JWT (ES256) issued in the TOKEN characteristic; passed as a query parameter; bound to the IMSI (mismatch → 511) |
| Temporary tokens | 2.8.6 / 2.8.7 | ODSA websheet flows; renewal is once-only |
| Web-view auth | 2.8.2 | Auto-login, login form, and SMS-OTP (codes readable from the admin OTP outbox) |
| OAuth 2.0 / OIDC | 2.8.3 | Embedded provider under /oidc/* — no external IdP |
| S2S operator tokens | 14 | ap2015: /oauth/token + /oauth/introspect; MDM/App-Server requests present a Bearer token |
| Debug bypass | — | ECS_ALLOW_AUTH_BYPASS for SIM-less emulators; disabled by default, refuses example secrets |
HTTP surface
| Path | Purpose |
|---|---|
GET/POST / |
The TS.43 entitlement endpoint — everything a device sends goes here (params as query string, per spec) |
GET /health, GET /ready |
Liveness / readiness |
/oidc/* |
Embedded OIDC provider (authorize, login, otp, token, jwks, callback) |
/oauth/token, /oauth/introspect |
ap2015 server-to-server tokens |
/websheet/odsa, /websheet/databoost (+ /complete) |
User-facing websheets for ODSA subscription and DataBoost purchase flows |
/admin/api/* |
Test-control API (below) |
Test-control (admin) API
Guarded by X-Admin-Key (constant-time compare) plus a source-IP allowlist and
per-IP rate limiting. Highlights:
POST /admin/api/subscribers create (also /bulk)
GET /admin/api/subscribers/{imsi} DELETE …
POST /admin/api/subscribers/{imsi}/sqn/advance
PUT /admin/api/entitlements/{imsi}/{app} override document parameters
POST /admin/api/profiles eSIM profiles (also /bulk)
GET /admin/api/odsa/state/{imsi} PUT /admin/api/odsa/{imsi}/{app}
POST /admin/api/odsa/{imsi}/{app}/download-ready
POST /admin/api/odsa/server-initiated
GET /admin/api/tokens POST …/{id}/revoke, POST …/expire
GET /admin/api/otp-outbox SMS-OTP codes for web-auth tests
GET /admin/api/notifications POST …/push (§2.6), GET /notif-outbox
GET /admin/api/traffic request inspector (+ /traffic/html)
POST /admin/api/chaos fault injection rules (GET/DELETE)
GET /admin/api/scenarios POST …/{name}/apply
POST /admin/api/snapshots/{name} state snapshots (+ /restore)
POST /admin/api/reset global test isolation
GET /admin/api/sessions live EAP session view
Chaos injection (delays, error codes, malformed bodies, matched by ts43ecs/imsi/path)
is additionally gated behind ECS_ALLOW_CHAOS_INJECT=true and delay-capped.
Scenario presets
carriers.yaml ships ready-made situations, each mapped to a GSMA TS.56 test
case — apply one and the subscribers, overrides, and faults it needs are set up
in one call:
curl -sk -X POST -H "X-Admin-Key: my-lab-key" \
https://127.0.0.1:8443/admin/api/scenarios/vowifi_tc_pending/apply
Included presets: vowifi_tc_pending, esim_transfer_delayed,
satellite_disaster_only, token_expired_511. Add your own by extending the
YAML (description, ts56, subscribers, overrides, settings,
expire_tokens, chaos).
Configuration
Everything is an environment variable with the ECS_ prefix (or .env; see
.env.example for the full list). The important ones:
| Variable | Default | Purpose |
|---|---|---|
ECS_HOST / ECS_PORT |
127.0.0.1 / 8443 |
Bind address (non-loopback requires strong credentials) |
ECS_SSL_CERTFILE / ECS_SSL_KEYFILE |
certs/server.crt / .key |
TLS material (use a public cert for real devices — see playbook) |
ECS_DATABASE_URL |
sqlite+aiosqlite:///./ecs.db |
Durable state |
ECS_ADMIN_API_KEY |
(unset — admin API disabled) | Test-control API key |
ECS_ADMIN_ALLOWED_IPS |
127.0.0.1,::1 |
Admin source-IP allowlist (empty = allow any) |
ECS_ADMIN_RATE_LIMIT_PER_MINUTE |
600 |
Per-IP sliding window; 0 disables |
ECS_TOKEN_TTL_SECONDS |
86400 |
Fast-auth token lifetime |
ECS_ALLOW_AUTH_BYPASS / ECS_ALLOW_CHAOS_INJECT |
false |
Test-only escape hatches |
ECS_SHUTDOWN_GRACE_SECONDS |
10 |
Graceful shutdown drain (0–120) |
Secrets are masked in logs and Settings.__repr__.
Testing with a real Android device
See docs/android_testing_playbook.md for
the full guide. The short version:
- TLS trust is the main hurdle — AOSP entitlement clients trust the system store only. Options: emulator with writable system, Android 14+ Conscrypt-APEX bind-mount (rooted), or a public certificate (zero device mods).
- Point the device at the server:
adb shell cmd phone cc set-value -p imsserviceentitlement.entitlement_server_url_string https://<host>:8443(the same key drives IMS and satellite entitlement). - SIM credentials: programmable SIMs (sysmoISIM) with known Ki/OPc, seeded via the admin API. First auth resyncs SQN automatically — that's expected.
- Watch everything the device does in the traffic inspector
(
/admin/api/traffic/html).
Architecture
Device / ecs_client
│ HTTPS (GET /, query params)
▼
transport/router.py ──► EAP relay? ──► eap/ (packet codec → FSM → Milenage)
│ normalize (case-insensitive params, UA, vers)
│ chaos (opt-in)
│ authenticate (token | temp token | S2S bearer | EAP | OIDC | bypass)
▼
apps/registry.py ──► per-AppID handler ──► odsa/dispatcher.py (ODSA ops)
│ │
│ HandlerContext (subscriber, overrides, request)
▼
documents/ (DocumentModel → WAP XML or JSON, VERS+TOKEN+APPLICATION)
- Storage split: SQLite (SQLAlchemy async + Alembic) for durable state —
subscribers, tokens, overrides, notification registrations; an in-process
MemoryStore(TTL caches, lock-wrapped) for EAP sessions, OTP/notification outboxes, traffic log, and chaos rules. - Single process, embedded everything: EAP engine, OIDC provider, websheets,
and admin plane all live in one FastAPI app (
ts43ecs/main.py:create_app). ts43ecs/tools/ecs_clientimplements the client side of the protocol and is what the integration and performance tests drive — CI needs no hardware.
Development
# Gates (all enforced in CI, py3.11–3.13)
ruff check .
mypy app tools # strict
pytest -q -m "not perf" --cov=app --cov-fail-under=90
bandit -q -c pyproject.toml -r ts43ecs/ ts43ecs/tools/ scripts/
pip-audit --skip-editable
# Perf smoke (advisory: 50 concurrent EAP handshakes, ≥100 fast-auth/s)
pytest -q -m perf
# Single test
pytest tests/unit/test_milenage.py -v
pytest -k "test_volte" -v
# Migrations (run from repo root; CI fails on model drift)
alembic upgrade head && alembic check
# Regenerate the matrices after touching @pytest.mark.spec markers / carriers.yaml
python -m ts43ecs.tools.gen_compliance_matrix
python -m ts43ecs.tools.gen_ts56_matrix
Compliance traceability is enforced, not aspirational: tests carry
@pytest.mark.spec("<TS.43 §>") markers, ts43ecs/tools/gen_compliance_matrix.py
generates the matrix, and a unit test fails the build if any of the 27 required
sections loses coverage.
Protocol fidelity notes
Where the spec and the real AOSP client disagree, this server follows the
client (verified against frameworks/libs/service_entitlement):
- EAP relay rides on HTTP 200 (not 401), Content-Type
application/vnd.gsma.eap-relay.v1.0+json - XML Content-Type is
text/vnd.wap.connectivity-xml - GET is the default method; the auth token and
versare query parameters - The client only accepts HTTP 200 and 302 — anything else throws on-device
- Parameter names are matched case-insensitively (AOSP lowercases them)
- Known AOSP quirks are reproduced, including the
NetworkVoiceIRATCapablityparameter-name typo
Key documentation
| File | Purpose |
|---|---|
docs/compliance_matrix.md |
Generated TS.43 v13 § → test traceability |
docs/ts56_matrix.md |
Generated TS.56 test case → scenario preset mapping |
docs/android_testing_playbook.md |
Real-device testing guide |
docs/console.md |
Browser test console at /console |
docs/operations.md |
Schema migrations, JWT key rotation, TLS floor |
docs/conformance.md |
AOSP differential testing and the replay corpus |
docs/AOSP_client_protocol_spec.md |
Android client protocol, extracted from AOSP source |
docs/TS43_XML_schema_reference.md |
Per-AppID XML formats with exact parameter names |
docs/eap_aka_packet_format_reference.md |
Byte-level EAP-AKA/AKA′ packet encoding |
docs/SECURITY_REVIEW.md |
Security posture and review notes |
docs/v1/ts43-ecs-impl-plan-fable5.md |
v1.0 implementation plan (historical) |
docs/v1.2/v1.2_improvement_plan.md |
v1.2 improvement plan (current scope source of truth) |
CHANGELOG.md |
Release history (Keep a Changelog) |
Security
See SECURITY.md for vulnerability reporting instructions and threat scope.
License
Apache-2.0
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ts43_test_ecs-1.3.0.tar.gz.
File metadata
- Download URL: ts43_test_ecs-1.3.0.tar.gz
- Upload date:
- Size: 1.1 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5f30c283783305f3726564b63b840b9b5cc658b4bb98904ea243010bdb844ce
|
|
| MD5 |
8bbb93f585fc5fbaf4948eed48227cf8
|
|
| BLAKE2b-256 |
001b3b740b986b58d948b46f07547f4d367d7ed798abb7a7428baf33752b3ecb
|
Provenance
The following attestation bundles were made for ts43_test_ecs-1.3.0.tar.gz:
Publisher:
release.yml on RamboXWang/ts43-test-ecs
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ts43_test_ecs-1.3.0.tar.gz -
Subject digest:
f5f30c283783305f3726564b63b840b9b5cc658b4bb98904ea243010bdb844ce - Sigstore transparency entry: 2180002365
- Sigstore integration time:
-
Permalink:
RamboXWang/ts43-test-ecs@55e9e1fbe589ca5c80e43fef0207a8be717c4566 -
Branch / Tag:
refs/tags/v1.3.0 - Owner: https://github.com/RamboXWang
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@55e9e1fbe589ca5c80e43fef0207a8be717c4566 -
Trigger Event:
push
-
Statement type:
File details
Details for the file ts43_test_ecs-1.3.0-py3-none-any.whl.
File metadata
- Download URL: ts43_test_ecs-1.3.0-py3-none-any.whl
- Upload date:
- Size: 198.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3070e5e759c7fa5d53fb5b100ee3e038dea123c706d595d7f7e31bd6676a600
|
|
| MD5 |
15d8e01ea35911de6084d5ace37323d9
|
|
| BLAKE2b-256 |
8c3b0db0c354956b316843d0dfe255a20b98e48700f3a5cc647443d5cdc2cac8
|
Provenance
The following attestation bundles were made for ts43_test_ecs-1.3.0-py3-none-any.whl:
Publisher:
release.yml on RamboXWang/ts43-test-ecs
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ts43_test_ecs-1.3.0-py3-none-any.whl -
Subject digest:
a3070e5e759c7fa5d53fb5b100ee3e038dea123c706d595d7f7e31bd6676a600 - Sigstore transparency entry: 2180002551
- Sigstore integration time:
-
Permalink:
RamboXWang/ts43-test-ecs@55e9e1fbe589ca5c80e43fef0207a8be717c4566 -
Branch / Tag:
refs/tags/v1.3.0 - Owner: https://github.com/RamboXWang
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@55e9e1fbe589ca5c80e43fef0207a8be717c4566 -
Trigger Event:
push
-
Statement type: