forked from public/pysim
Compare commits
229 Commits
pmaier/pgsql
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c437d41e0 | |||
| 2b7abdcf96 | |||
| aeba4004de | |||
| 632d585cfc | |||
| 7e8f711ec2 | |||
| d671cee649 | |||
| eb8e40948b | |||
| 0de8274e99 | |||
| df8de1a69a | |||
| 6b40fe8546 | |||
| 456c7873eb | |||
| 1b8c6b48ea | |||
| fd83fbdb5f | |||
| ff3f275c84 | |||
| a0e14a16f2 | |||
| 37719a0fcf | |||
| 26a3fc09dc | |||
| 515925228d | |||
| da94468c5f | |||
| 1c9072541b | |||
| e4e491ce58 | |||
| e70d9ec0c9 | |||
| 6076e4e6ff | |||
| 6313b83e0e | |||
| 5a54dd9eda | |||
| 63d4c447fb | |||
| 1e41568c12 | |||
| 2761d16582 | |||
| aeba4a547c | |||
| a8a94eae9c | |||
| 41e0d532f0 | |||
| e03530f89a | |||
| 078ac2bf19 | |||
| c582b5fee3 | |||
| d4717bd014 | |||
| 1cfb0f3da2 | |||
| cb3eb77236 | |||
| f381255639 | |||
| d13be84ccd | |||
| f4eb2f9356 | |||
| bb362482e8 | |||
| 9c77e4ed94 | |||
| ab19049d19 | |||
| 25e43e1540 | |||
| 6e10da4c55 | |||
| 973d6eb2cc | |||
| 597f1e0398 | |||
| 45d37ed959 | |||
| 757c7d048e | |||
| d0e6a1b119 | |||
| 980282cc12 | |||
| 728940efb2 | |||
| cfe2b94f67 | |||
| 861ed0a1d8 | |||
| b576e8fcff | |||
| 38f93d974b | |||
| c5e7e59928 | |||
| 98af3dd2e9 | |||
| e9ff4f3b93 | |||
| ce039d69ba | |||
| aad92f2b73 | |||
| 512aba8b1d | |||
| b5ba274583 | |||
| 4307cffc82 | |||
| bfdfcad22c | |||
| ef0a2fcb37 | |||
| 3974e96933 | |||
| a7c762eb2e | |||
| 710a27d6cf | |||
| 08f40db8a3 | |||
| 4fb393e6ea | |||
| ce5da32a75 | |||
| 9ddd235a2c | |||
| 77eb30a782 | |||
| 2530329ae2 | |||
| f9e4291a43 | |||
| 20538775b2 | |||
| ef58c94dfe | |||
| 810c51c38f | |||
| 66d3b54f92 | |||
| 7d11f91778 | |||
| 58a324126e | |||
| 3cd5c41fb4 | |||
| 593bfa0911 | |||
| 8fa7727a14 | |||
| f1609424de | |||
| 1167b65e2a | |||
| cd4b01f67e | |||
| 393de033d3 | |||
| 5f1c7d603c | |||
| d7072e9263 | |||
| ac593bb14d | |||
| a95622a022 | |||
| 03b58985a5 | |||
| cc71dbf899 | |||
| aafc8d51c3 | |||
| c50f4b4a02 | |||
| 816b31eb07 | |||
| f2567de387 | |||
| 6b5fa38f14 | |||
| 45220e00d5 | |||
| 5828c92c66 | |||
| 5e2fd148f8 | |||
| fc932a2ee9 | |||
| d5aa963caa | |||
| 19245d0d8b | |||
| a786590906 | |||
| ca8fada7b6 | |||
| c995bb1ec2 | |||
| ee06ab987f | |||
| a1d3b8f5e8 | |||
| f7b86e1920 | |||
| 2cfb0972df | |||
| 4215a3bfd3 | |||
| b42d417bbe | |||
| 74ac191ae6 | |||
| add4b991b7 | |||
| 8c81e2cdf9 | |||
| d9d62ee729 | |||
| c7e68e1281 | |||
| 969f9c0e4b | |||
| 2ef9abf23e | |||
| 473f31066c | |||
| b59363b49e | |||
| 115b517c6a | |||
| 99aef1fecf | |||
| caddd1c7a0 | |||
| 11a7a7e3b1 | |||
| 5138208ee6 | |||
| 5b2fabde62 | |||
| 24127e985a | |||
| 09ae327f8b | |||
| d32bce19f6 | |||
| 83bfdc0d3b | |||
| 14ec52a06c | |||
| 209d13e233 | |||
| 3b50e64c8b | |||
| b76cc80ea1 | |||
| 3b87ba3cba | |||
| ea1d5af383 | |||
| 0634f77308 | |||
| a5a5865c7c | |||
| 3752aeb94e | |||
| 914abe3309 | |||
| 84754b6ebb | |||
| c47005d408 | |||
| 2dfaac6e4f | |||
| a615ba5138 | |||
| 8ee10ab1a5 | |||
| f10af30aed | |||
| d8f3c78135 | |||
| 6b9b46a5a4 | |||
| b6b4501e37 | |||
| 54658fa3a9 | |||
| eb04bb1082 | |||
| 453fde5a3a | |||
| 57237b650e | |||
| 1f94791240 | |||
| 1a28575327 | |||
| e7016b5b57 | |||
| e80f3160a9 | |||
| 917ad7f9f5 | |||
| 8b2a49aa8e | |||
| 7ee7173a2f | |||
| 0f99598b34 | |||
| d7901ef08d | |||
| edfac26824 | |||
| 07a3978748 | |||
| a297cdba73 | |||
| f9d7c82b4d | |||
| c6fa2b4007 | |||
| 39d744010a | |||
| 15691233e1 | |||
| 0a1c5a27d7 | |||
| e0a9e73267 | |||
| 22c3797a89 | |||
| 4e35e2c357 | |||
| e62f160775 | |||
| 1f2db11d31 | |||
| ae91245582 | |||
| 429b12c8b5 | |||
| ccc1a047ab | |||
| db17529136 | |||
| 1c082da0ee | |||
| 1e98856105 | |||
| ae656c66a3 | |||
| d5b570b01d | |||
| 21641816ea | |||
| 742baeab56 | |||
| a4895702d7 | |||
| 2b42877389 | |||
| 167d6aca36 | |||
| d8c45dc07e | |||
| 0a36ba257c | |||
| 1f36c9c28a | |||
| e00c0becca | |||
| 148d0a6f90 | |||
| 51da6263b7 | |||
| 4f1d7d7ac6 | |||
| 8557ec86be | |||
| 2e7944cc98 | |||
| 1347d5ffa2 | |||
| fddab8639f | |||
| eb7c5d85d0 | |||
| eda6182edd | |||
| 725ffffda1 | |||
| 777d005350 | |||
| 6e9625213a | |||
| 4c8a9478c2 | |||
| dfe4d9c8ac | |||
| 8e048820d4 | |||
| c2ace3d8cf | |||
| 097d565310 | |||
| a8ae89a041 | |||
| d764659a30 | |||
| 3ca25219bc | |||
| 1da34c1a4f | |||
| 381519556c | |||
| 0fe432fec9 | |||
| c6fd1d314a | |||
| 88aff4c577 | |||
| 5fe76bb680 | |||
| c058c6a34d | |||
| 3d42106ad9 | |||
| 9a23eab163 | |||
| 82b57403c7 | |||
| a62fb2b987 | |||
| 111f9da4f5 | |||
| ddbf91fc4a |
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
.*.swp
|
.*.sw?
|
||||||
|
|
||||||
/docs/_*
|
/docs/_*
|
||||||
/docs/generated
|
/docs/generated
|
||||||
|
/docs/filesystem.rst
|
||||||
/.cache
|
/.cache
|
||||||
/.local
|
/.local
|
||||||
/build
|
/build
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ Please install the following dependencies:
|
|||||||
- pyscard
|
- pyscard
|
||||||
- pyserial
|
- pyserial
|
||||||
- pytlv
|
- pytlv
|
||||||
- pyyaml >= 5.1
|
- pyyaml >= 5.4
|
||||||
- smpp.pdu (from `github.com/hologram-io/smpp.pdu`)
|
- smpp.pdu (from `github.com/hologram-io/smpp.pdu`)
|
||||||
- termcolor
|
- termcolor
|
||||||
|
|
||||||
|
|||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# A tool to analyze the eUICC simaResponse (series of EUICCResponse)
|
||||||
|
#
|
||||||
|
# (C) 2025 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import argparse
|
||||||
|
from osmocom.utils import h2b, b2h
|
||||||
|
from osmocom.tlv import bertlv_parse_one, bertlv_encode_tag, bertlv_encode_len
|
||||||
|
from pySim.esim.saip import *
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="""Utility program to analyze the contents of an eUICC simaResponse.""")
|
||||||
|
parser.add_argument('SIMA_RESPONSE', help='Hexstring containing the simaResponse as received from the eUICC')
|
||||||
|
|
||||||
|
def split_sima_response(sima_response):
|
||||||
|
"""split an eUICC simaResponse field into a list of EUICCResponse fields"""
|
||||||
|
|
||||||
|
remainder = sima_response
|
||||||
|
result = []
|
||||||
|
while len(remainder):
|
||||||
|
tdict, l, v, next_remainder = bertlv_parse_one(remainder)
|
||||||
|
rawtag = bertlv_encode_tag(tdict)
|
||||||
|
rawlen = bertlv_encode_len(l)
|
||||||
|
result = result + [remainder[0:len(rawtag) + len(rawlen) + l]]
|
||||||
|
remainder = next_remainder
|
||||||
|
return result
|
||||||
|
|
||||||
|
def analyze_status(status):
|
||||||
|
"""
|
||||||
|
Convert a status code (integer) into a human readable string
|
||||||
|
(see eUICC Profile Package: Interoperable Format Technical Specification, section 8.11)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# SIMA status codes
|
||||||
|
string_values = {0 : 'ok',
|
||||||
|
1 : 'pe-not-supported',
|
||||||
|
2 : 'memory-failure',
|
||||||
|
3 : 'bad-values',
|
||||||
|
4 : 'not-enough-memory',
|
||||||
|
5 : 'invalid-request-format',
|
||||||
|
6 : 'invalid-parameter',
|
||||||
|
7 : 'runtime-not-supported',
|
||||||
|
8 : 'lib-not-supported',
|
||||||
|
9 : 'template-not-supported ',
|
||||||
|
10 : 'feature-not-supported',
|
||||||
|
11 : 'pin-code-missing',
|
||||||
|
31 : 'unsupported-profile-version'}
|
||||||
|
|
||||||
|
string_value = string_values.get(status, None)
|
||||||
|
if string_value is not None:
|
||||||
|
return "%d = %s (SIMA status code)" % (status, string_value)
|
||||||
|
|
||||||
|
# ISO 7816 status words
|
||||||
|
if status >= 24576 and status <= 28671:
|
||||||
|
return "%d = %04x (ISO7816 status word)" % (status, status)
|
||||||
|
elif status >= 36864 and status <= 40959:
|
||||||
|
return "%d = %04x (ISO7816 status word)" % (status, status)
|
||||||
|
|
||||||
|
# Proprietary status codes
|
||||||
|
elif status >= 40960 and status <= 65535:
|
||||||
|
return "%d = %04x (proprietary)" % (status, status)
|
||||||
|
|
||||||
|
# Unknown status codes
|
||||||
|
return "%d (unknown, proprietary?)" % status
|
||||||
|
|
||||||
|
def analyze_euicc_response(euicc_response):
|
||||||
|
"""Analyze and display the contents of an EUICCResponse"""
|
||||||
|
|
||||||
|
print(" EUICCResponse: %s" % b2h(euicc_response))
|
||||||
|
euicc_response_decoded = asn1.decode('EUICCResponse', euicc_response)
|
||||||
|
|
||||||
|
pe_status = euicc_response_decoded.get('peStatus')
|
||||||
|
print(" peStatus:")
|
||||||
|
for s in pe_status:
|
||||||
|
print(" status: %s" % analyze_status(s.get('status')))
|
||||||
|
print(" identification: %s" % str(s.get('identification', None)))
|
||||||
|
print(" additional-information: %s" % str(s.get('additional-information', None)))
|
||||||
|
print(" offset: %s" % str(s.get('offset', None)))
|
||||||
|
|
||||||
|
if euicc_response_decoded.get('profileInstallationAborted', False) is None:
|
||||||
|
# This type is defined as profileInstallationAborted NULL OPTIONAL, so when it is present it
|
||||||
|
# will have the value None, otherwise it is simply not present.
|
||||||
|
print(" profileInstallationAborted: True")
|
||||||
|
else:
|
||||||
|
print(" profileInstallationAborted: False")
|
||||||
|
|
||||||
|
status_message = euicc_response_decoded.get('statusMessage', None)
|
||||||
|
print(" statusMessage: %s" % str(status_message))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
opts = parser.parse_args()
|
||||||
|
sima_response = h2b(opts.SIMA_RESPONSE);
|
||||||
|
|
||||||
|
print("simaResponse: %s" % b2h(sima_response))
|
||||||
|
euicc_response_list = split_sima_response(sima_response)
|
||||||
|
|
||||||
|
for euicc_response in euicc_response_list:
|
||||||
|
analyze_euicc_response(euicc_response)
|
||||||
Executable
+301
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# (C) 2025 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Philipp Maier
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.sql import Identifier, SQL
|
||||||
|
from pathlib import Path
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
log = PySimLogger.get(Path(__file__).stem)
|
||||||
|
|
||||||
|
class CardKeyDatabase:
|
||||||
|
def __init__(self, config_filename: str, table_name: str, create_table: bool = False, admin: bool = False):
|
||||||
|
"""
|
||||||
|
Initialize database connection and set the table which shall be used as storage for the card key data.
|
||||||
|
In case the specified table does not exist yet it can be created using the create_table_type parameter.
|
||||||
|
|
||||||
|
New tables are always minimal tables which follow a pre-defined table scheme. The user may extend the table
|
||||||
|
with additional columns using the add_cols() later.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tablename : name of the database table to create.
|
||||||
|
create_table_type : type of the table to create ('UICC' or 'EUICC')
|
||||||
|
"""
|
||||||
|
|
||||||
|
def user_from_config_file(config, role: str) -> tuple[str, str]:
|
||||||
|
db_users = config.get('db_users')
|
||||||
|
user = db_users.get(role)
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("user for role '%s' not set up in config file." % role)
|
||||||
|
return user.get('name'), user.get('pass')
|
||||||
|
|
||||||
|
self.table = table_name.lower()
|
||||||
|
self.cols = None
|
||||||
|
|
||||||
|
# Depending on the table type, the table name must contain either the substring "uicc_keys" or "euicc_keys".
|
||||||
|
# This convention will allow us to deduct the table type from the table name.
|
||||||
|
if "euicc_keys" not in table_name and "uicc_keys" not in table_name:
|
||||||
|
raise ValueError("Table name (%s) should contain the substring \"uicc_keys\" or \"euicc_keys\"" % table_name)
|
||||||
|
|
||||||
|
# Read config file
|
||||||
|
log.info("Using config file: %s", config_filename)
|
||||||
|
with open(config_filename, "r") as cfg:
|
||||||
|
config = yaml.load(cfg, Loader=yaml.FullLoader)
|
||||||
|
host = config.get('host')
|
||||||
|
log.info("Database host: %s", host)
|
||||||
|
db_name = config.get('db_name')
|
||||||
|
log.info("Database name: %s", db_name)
|
||||||
|
table_names = config.get('table_names')
|
||||||
|
username_admin, password_admin = user_from_config_file(config, 'admin')
|
||||||
|
username_importer, password_importer = user_from_config_file(config, 'importer')
|
||||||
|
username_reader, _ = user_from_config_file(config, 'reader')
|
||||||
|
|
||||||
|
# Switch between admin and importer user
|
||||||
|
if admin:
|
||||||
|
username, password = username_admin, password_admin
|
||||||
|
else:
|
||||||
|
username, password = username_importer, password_importer
|
||||||
|
|
||||||
|
# Create database connection
|
||||||
|
log.info("Database user: %s", username)
|
||||||
|
self.conn = psycopg2.connect(dbname=db_name, user=username, password=password, host=host)
|
||||||
|
self.cur = self.conn.cursor()
|
||||||
|
|
||||||
|
# In the context of this tool it is not relevant if the table name is present in the config file. However,
|
||||||
|
# pySim-shell.py will require the table name to be configured properly to access the database table.
|
||||||
|
if self.table not in table_names:
|
||||||
|
log.warning("Specified table name (%s) is not yet present in config file (required for access from pySim-shell.py)",
|
||||||
|
self.table)
|
||||||
|
|
||||||
|
# Create a new minimal database table of the specified table type.
|
||||||
|
if create_table:
|
||||||
|
if not admin:
|
||||||
|
raise ValueError("creation of new table refused, use option --admin and try again.")
|
||||||
|
if "euicc_keys" in self.table:
|
||||||
|
self.__create_table(username_reader, username_importer, ['EID'])
|
||||||
|
elif "uicc_keys" in self.table:
|
||||||
|
self.__create_table(username_reader, username_importer, ['ICCID', 'IMSI'])
|
||||||
|
|
||||||
|
# Ensure a table with the specified name exists
|
||||||
|
log.info("Database table: %s", self.table)
|
||||||
|
if self.get_cols() == []:
|
||||||
|
raise ValueError("Table name (%s) does not exist yet" % self.table)
|
||||||
|
log.info("Database table columns: %s", str(self.get_cols()))
|
||||||
|
|
||||||
|
def __create_table(self, user_reader:str, user_importer:str, cols:list[str]):
|
||||||
|
"""
|
||||||
|
Initialize a new table. New tables are always minimal tables with one primary key and additional index columns.
|
||||||
|
Non index-columns may be added later using method _update_cols().
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create table columns with primary key
|
||||||
|
query = SQL("CREATE TABLE {} ({} VARCHAR PRIMARY KEY").format(Identifier(self.table),
|
||||||
|
Identifier(cols[0].lower()))
|
||||||
|
for c in cols[1:]:
|
||||||
|
query += SQL(", {} VARCHAR").format(Identifier(c.lower()))
|
||||||
|
query += SQL(");")
|
||||||
|
self.cur.execute(query)
|
||||||
|
|
||||||
|
# Create indexes for all other columns
|
||||||
|
for c in cols[1:]:
|
||||||
|
self.cur.execute(query = SQL("CREATE INDEX {} ON {}({});").format(Identifier(c.lower()),
|
||||||
|
Identifier(self.table),
|
||||||
|
Identifier(c.lower())))
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
self.cur.execute(SQL("GRANT INSERT ON {} TO {};").format(Identifier(self.table),
|
||||||
|
Identifier(user_importer)))
|
||||||
|
self.cur.execute(SQL("GRANT SELECT ON {} TO {};").format(Identifier(self.table),
|
||||||
|
Identifier(user_reader)))
|
||||||
|
|
||||||
|
log.info("New database table created: %s", self.table)
|
||||||
|
|
||||||
|
def get_cols(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get a list of all columns available in the current table scheme.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list with column names (in uppercase) of the database table
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Return cached col list if present
|
||||||
|
if self.cols:
|
||||||
|
return self.cols
|
||||||
|
|
||||||
|
# Request a list of current cols from the database
|
||||||
|
self.cur.execute("SELECT column_name FROM information_schema.columns where table_name = %s;", (self.table,))
|
||||||
|
|
||||||
|
cols_result = self.cur.fetchall()
|
||||||
|
cols = []
|
||||||
|
for c in cols_result:
|
||||||
|
cols.append(c[0].upper())
|
||||||
|
self.cols = cols
|
||||||
|
return cols
|
||||||
|
|
||||||
|
def get_missing_cols(self, cols_expected:list[str]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Check if the current table scheme lacks any of the given expected columns.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list with the missing columns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cols_present = self.get_cols()
|
||||||
|
return list(set(cols_expected) - set(cols_present))
|
||||||
|
|
||||||
|
def add_cols(self, cols:list[str]):
|
||||||
|
"""
|
||||||
|
Update the current table scheme with additional columns. In case the updated columns are already exist, the
|
||||||
|
table schema is not changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
table : name of the database table to alter
|
||||||
|
cols : list with updated colum names to add
|
||||||
|
"""
|
||||||
|
|
||||||
|
cols_missing = self.get_missing_cols(cols)
|
||||||
|
|
||||||
|
# Depending on the table type (see constructor), we either have a primary key 'ICCID' (for UICC data), or 'EID'
|
||||||
|
# (for eUICC data). Both table formats different types of data and have rather differen columns also. Let's
|
||||||
|
# prevent the excidentally mixing of both types.
|
||||||
|
if 'ICCID' in cols_missing:
|
||||||
|
raise ValueError("Table %s stores eUCCC key material, refusing to add UICC specific column 'ICCID'" % self.table)
|
||||||
|
if 'EID' in cols_missing:
|
||||||
|
raise ValueError("Table %s stores UCCC key material, refusing to add eUICC specific column 'EID'" % self.table)
|
||||||
|
|
||||||
|
# Add the missing columns to the table
|
||||||
|
self.cols = None
|
||||||
|
for c in cols_missing:
|
||||||
|
self.cur.execute(query = SQL("ALTER TABLE {} ADD {} VARCHAR;").format(Identifier(self.table),
|
||||||
|
Identifier(c.lower())))
|
||||||
|
|
||||||
|
def insert_row(self, row:dict[str, str]):
|
||||||
|
"""
|
||||||
|
Insert a new row into the database table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
row : dictionary with the colum names and their designated values
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check if the row is compatible with the current table scheme
|
||||||
|
cols_expected = list(row.keys())
|
||||||
|
cols_missing = self.get_missing_cols(cols_expected)
|
||||||
|
if cols_missing != []:
|
||||||
|
raise ValueError("table %s has incompatible format, the row %s contains unknown cols %s" %
|
||||||
|
(self.table, str(row), str(cols_missing)))
|
||||||
|
|
||||||
|
# Insert row into datbase table
|
||||||
|
row_keys = list(row.keys())
|
||||||
|
row_values = list(row.values())
|
||||||
|
query = SQL("INSERT INTO {} ").format(Identifier(self.table))
|
||||||
|
query += SQL("({} ").format(Identifier(row_keys[0].lower()))
|
||||||
|
for k in row_keys[1:]:
|
||||||
|
query += SQL(", {}").format(Identifier(k.lower()))
|
||||||
|
query += SQL(") VALUES (%s")
|
||||||
|
for v in row_values[1:]:
|
||||||
|
query += SQL(", %s")
|
||||||
|
query += SQL(");")
|
||||||
|
self.cur.execute(query, row_values)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.conn.commit()
|
||||||
|
log.info("Changes to table %s committed!", self.table)
|
||||||
|
|
||||||
|
def open_csv(opts: argparse.Namespace):
|
||||||
|
log.info("CSV file: %s", opts.csv)
|
||||||
|
csv_file = open(opts.csv, 'r')
|
||||||
|
cr = csv.DictReader(csv_file)
|
||||||
|
if not cr:
|
||||||
|
raise RuntimeError("could not open DictReader for CSV-File '%s'" % opts.csv)
|
||||||
|
cr.fieldnames = [field.upper() for field in cr.fieldnames]
|
||||||
|
log.info("CSV file columns: %s", str(cr.fieldnames))
|
||||||
|
return cr
|
||||||
|
|
||||||
|
def open_db(cr: csv.DictReader, opts: argparse.Namespace) -> CardKeyDatabase:
|
||||||
|
try:
|
||||||
|
db = CardKeyDatabase(os.path.expanduser(opts.pgsql), opts.table_name, opts.create_table, opts.admin)
|
||||||
|
|
||||||
|
# Check CSV format against table schema, add missing columns
|
||||||
|
cols_missing = db.get_missing_cols(cr.fieldnames)
|
||||||
|
if cols_missing != [] and (opts.update_columns or opts.create_table):
|
||||||
|
log.info("Adding missing columns: %s", str(cols_missing))
|
||||||
|
db.add_cols(cols_missing)
|
||||||
|
cols_missing = db.get_missing_cols(cr.fieldnames)
|
||||||
|
|
||||||
|
# Make sure the table schema has no missing columns
|
||||||
|
if cols_missing != []:
|
||||||
|
log.error("Database table lacks CSV file columns: %s -- import aborted!", cols_missing)
|
||||||
|
sys.exit(2)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(str(e).strip())
|
||||||
|
log.error("Database initialization aborted due to error!")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
return db
|
||||||
|
|
||||||
|
def import_from_csv(db: CardKeyDatabase, cr: csv.DictReader):
|
||||||
|
count = 0
|
||||||
|
for row in cr:
|
||||||
|
try:
|
||||||
|
db.insert_row(row)
|
||||||
|
count+=1
|
||||||
|
if count % 100 == 0:
|
||||||
|
log.info("CSV file import in progress, %d rows imported...", count)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(str(e).strip())
|
||||||
|
log.error("CSV file import aborted due to error, no datasets committed!")
|
||||||
|
sys.exit(2)
|
||||||
|
log.info("CSV file import done, %d rows imported", count)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
option_parser = argparse.ArgumentParser(description='CSV importer for pySim-shell\'s PostgreSQL Card Key Provider',
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||||
|
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
|
||||||
|
option_parser.add_argument('--pgsql', metavar='FILE',
|
||||||
|
default="~/.osmocom/pysim/card_data_pgsql.cfg",
|
||||||
|
help='Read card data from PostgreSQL database (config file)')
|
||||||
|
option_parser.add_argument('--csv', metavar='FILE', help='input CSV file with card data', required=True)
|
||||||
|
option_parser.add_argument("--table-name", help="name of the card key table", type=str, required=True)
|
||||||
|
option_parser.add_argument("--update-columns", help="add missing table columns", action='store_true', default=False)
|
||||||
|
option_parser.add_argument("--create-table", action='store_true', help="create new card key table", default=False)
|
||||||
|
option_parser.add_argument("--admin", action='store_true', help="perform action as admin", default=False)
|
||||||
|
opts = option_parser.parse_args()
|
||||||
|
|
||||||
|
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
|
||||||
|
|
||||||
|
# Open CSV file
|
||||||
|
cr = open_csv(opts)
|
||||||
|
|
||||||
|
# Open database, create initial table, update column scheme
|
||||||
|
db = open_db(cr, opts)
|
||||||
|
|
||||||
|
# Progress with import
|
||||||
|
if not opts.admin:
|
||||||
|
import_from_csv(db, cr)
|
||||||
|
|
||||||
|
# Commit changes to the database
|
||||||
|
db.commit()
|
||||||
Executable
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Philipp Maier
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import asn1tools
|
||||||
|
import asn1tools.codecs.ber
|
||||||
|
import asn1tools.codecs.der
|
||||||
|
import pySim.esim.rsp as rsp
|
||||||
|
import pySim.esim.saip as saip
|
||||||
|
from pySim.esim.es2p import param, Es2pApiServerMno, Es2pApiServerHandlerMno
|
||||||
|
from osmocom.utils import b2h
|
||||||
|
from datetime import datetime
|
||||||
|
from analyze_simaResponse import split_sima_response
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(Path(__file__).stem)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="""
|
||||||
|
Utility to receive and log requests against the ES2+ API of an SM-DP+ according to GSMA SGP.22.""")
|
||||||
|
parser.add_argument("--host", help="Host/IP to bind HTTP(S) to", default="localhost")
|
||||||
|
parser.add_argument("--port", help="TCP port to bind HTTP(S) to", default=443, type=int)
|
||||||
|
parser.add_argument('--server-cert', help='X.509 server certificate used to provide the ES2+ HTTPs service')
|
||||||
|
parser.add_argument('--client-ca-cert', help='X.509 CA certificates to authenticate the requesting client(s)')
|
||||||
|
parser.add_argument("-v", "--verbose", help="enable debug output", action='store_true', default=False)
|
||||||
|
|
||||||
|
def decode_sima_response(sima_response):
|
||||||
|
decoded = []
|
||||||
|
euicc_response_list = split_sima_response(sima_response)
|
||||||
|
for euicc_response in euicc_response_list:
|
||||||
|
decoded.append(saip.asn1.decode('EUICCResponse', euicc_response))
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def decode_result_data(result_data):
|
||||||
|
return rsp.asn1.decode('PendingNotification', result_data)
|
||||||
|
|
||||||
|
def decode(data, path="/"):
|
||||||
|
if data is None:
|
||||||
|
return 'none'
|
||||||
|
elif type(data) is datetime:
|
||||||
|
return data.isoformat()
|
||||||
|
elif type(data) is tuple:
|
||||||
|
return {str(data[0]) : decode(data[1], path + str(data[0]) + "/")}
|
||||||
|
elif type(data) is list:
|
||||||
|
new_data = []
|
||||||
|
for item in data:
|
||||||
|
new_data.append(decode(item, path))
|
||||||
|
return new_data
|
||||||
|
elif type(data) is bytes:
|
||||||
|
return b2h(data)
|
||||||
|
elif type(data) is dict:
|
||||||
|
new_data = {}
|
||||||
|
for key, item in data.items():
|
||||||
|
new_key = str(key)
|
||||||
|
if path == '/' and new_key == 'resultData':
|
||||||
|
new_item = decode_result_data(item)
|
||||||
|
elif (path == '/resultData/profileInstallationResult/profileInstallationResultData/finalResult/successResult/' \
|
||||||
|
or path == '/resultData/profileInstallationResult/profileInstallationResultData/finalResult/errorResult/') \
|
||||||
|
and new_key == 'simaResponse':
|
||||||
|
new_item = decode_sima_response(item)
|
||||||
|
else:
|
||||||
|
new_item = item
|
||||||
|
new_data[new_key] = decode(new_item, path + new_key + "/")
|
||||||
|
return new_data
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
class Es2pApiServerHandlerForLogging(Es2pApiServerHandlerMno):
|
||||||
|
def call_handleDownloadProgressInfo(self, data: dict) -> (dict, str):
|
||||||
|
logging.info("ES2+:handleDownloadProgressInfo: %s" % json.dumps(decode(data)))
|
||||||
|
return {}, None
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
Es2pApiServerMno(args.port, args.host, Es2pApiServerHandlerForLogging(), args.server_cert, args.client_ca_cert)
|
||||||
|
|
||||||
@@ -126,14 +126,14 @@ class Es9pClient:
|
|||||||
if self.opts.iccid:
|
if self.opts.iccid:
|
||||||
ntf_metadata['iccid'] = h2b(swap_nibbles(self.opts.iccid))
|
ntf_metadata['iccid'] = h2b(swap_nibbles(self.opts.iccid))
|
||||||
|
|
||||||
if self.opts.operation == 'download':
|
if self.opts.operation == 'install':
|
||||||
pird = {
|
pird = {
|
||||||
'transactionId': self.opts.transaction_id,
|
'transactionId': h2b(self.opts.transaction_id),
|
||||||
'notificationMetadata': ntf_metadata,
|
'notificationMetadata': ntf_metadata,
|
||||||
'smdpOid': self.opts.smdpp_oid,
|
'smdpOid': self.opts.smdpp_oid,
|
||||||
'finalResult': ('successResult', {
|
'finalResult': ('successResult', {
|
||||||
'aid': self.opts.isdp_aid,
|
'aid': h2b(self.opts.isdp_aid),
|
||||||
'simaResponse': self.opts.sima_response,
|
'simaResponse': h2b(self.opts.sima_response),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
pird_bin = rsp.asn1.encode('ProfileInstallationResultData', pird)
|
pird_bin = rsp.asn1.encode('ProfileInstallationResultData', pird)
|
||||||
|
|||||||
+23
-14
@@ -10,6 +10,11 @@
|
|||||||
|
|
||||||
export PYTHONUNBUFFERED=1
|
export PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
setup_venv() {
|
||||||
|
virtualenv -p python3 venv --system-site-packages
|
||||||
|
. venv/bin/activate
|
||||||
|
}
|
||||||
|
|
||||||
if [ ! -d "./tests/" ] ; then
|
if [ ! -d "./tests/" ] ; then
|
||||||
echo "###############################################"
|
echo "###############################################"
|
||||||
echo "Please call from pySim-prog top directory"
|
echo "Please call from pySim-prog top directory"
|
||||||
@@ -23,8 +28,7 @@ fi
|
|||||||
|
|
||||||
case "$JOB_TYPE" in
|
case "$JOB_TYPE" in
|
||||||
"test")
|
"test")
|
||||||
virtualenv -p python3 venv --system-site-packages
|
setup_venv
|
||||||
. venv/bin/activate
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install pyshark
|
pip install pyshark
|
||||||
@@ -32,20 +36,27 @@ case "$JOB_TYPE" in
|
|||||||
# Execute automatically discovered unit tests first
|
# Execute automatically discovered unit tests first
|
||||||
python -m unittest discover -v -s tests/unittests
|
python -m unittest discover -v -s tests/unittests
|
||||||
|
|
||||||
# Run pySim-prog integration tests (requires physical cards)
|
|
||||||
cd tests/pySim-prog_test/
|
|
||||||
./pySim-prog_test.sh
|
|
||||||
cd ../../
|
|
||||||
|
|
||||||
# Run pySim-trace test
|
# Run pySim-trace test
|
||||||
tests/pySim-trace_test/pySim-trace_test.sh
|
tests/pySim-trace_test/pySim-trace_test.sh
|
||||||
|
;;
|
||||||
|
"card-test") # tests requiring physical cards
|
||||||
|
setup_venv
|
||||||
|
|
||||||
# Run pySim-shell integration tests (requires physical cards)
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run pySim-prog integration tests
|
||||||
|
cd tests/pySim-prog_test/
|
||||||
|
./pySim-prog_test.sh
|
||||||
|
cd ../../
|
||||||
|
|
||||||
|
# Run pySim-shell integration tests
|
||||||
python3 -m unittest discover -v -s ./tests/pySim-shell_test/
|
python3 -m unittest discover -v -s ./tests/pySim-shell_test/
|
||||||
|
|
||||||
|
# Run pySim-smpp2sim test
|
||||||
|
tests/pySim-smpp2sim_test/pySim-smpp2sim_test.sh
|
||||||
;;
|
;;
|
||||||
"distcheck")
|
"distcheck")
|
||||||
virtualenv -p python3 venv --system-site-packages
|
setup_venv
|
||||||
. venv/bin/activate
|
|
||||||
|
|
||||||
pip install .
|
pip install .
|
||||||
pip install pyshark
|
pip install pyshark
|
||||||
@@ -58,8 +69,7 @@ case "$JOB_TYPE" in
|
|||||||
# Print pylint version
|
# Print pylint version
|
||||||
pip3 freeze | grep pylint
|
pip3 freeze | grep pylint
|
||||||
|
|
||||||
virtualenv -p python3 venv --system-site-packages
|
setup_venv
|
||||||
. venv/bin/activate
|
|
||||||
|
|
||||||
pip install .
|
pip install .
|
||||||
|
|
||||||
@@ -77,8 +87,7 @@ case "$JOB_TYPE" in
|
|||||||
contrib/*.py
|
contrib/*.py
|
||||||
;;
|
;;
|
||||||
"docs")
|
"docs")
|
||||||
virtualenv -p python3 venv --system-site-packages
|
setup_venv
|
||||||
. venv/bin/activate
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ parser_esrv.add_argument('--output-file', required=True, help='Output file name'
|
|||||||
parser_esrv.add_argument('--add-flag', default=[], choices=esrv_flag_choices, action='append', help='Add flag to mandatory services list')
|
parser_esrv.add_argument('--add-flag', default=[], choices=esrv_flag_choices, action='append', help='Add flag to mandatory services list')
|
||||||
parser_esrv.add_argument('--remove-flag', default=[], choices=esrv_flag_choices, action='append', help='Remove flag from mandatory services list')
|
parser_esrv.add_argument('--remove-flag', default=[], choices=esrv_flag_choices, action='append', help='Remove flag from mandatory services list')
|
||||||
|
|
||||||
parser_info = subparsers.add_parser('tree', help='Display the filesystem tree')
|
parser_tree = subparsers.add_parser('tree', help='Display the filesystem tree')
|
||||||
|
|
||||||
def write_pes(pes: ProfileElementSequence, output_file:str):
|
def write_pes(pes: ProfileElementSequence, output_file:str):
|
||||||
"""write the PE sequence to a file"""
|
"""write the PE sequence to a file"""
|
||||||
|
|||||||
Executable
+524
@@ -0,0 +1,524 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""TLS-PSK HTTP Remote Administration Server for SCP81 / RAM over HTTP"""
|
||||||
|
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Eric Wild <ewild@sysmocom.de>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# The card (specifically a SD supporting SCP81) is the TLS client:
|
||||||
|
# - it opens a TCP connection to this server,
|
||||||
|
# - performs a TLS handshake authenticated with a PSK,
|
||||||
|
# - and then drives the HTTP admin loop of to fetch remote APDU command strings
|
||||||
|
# - and posts back their responses.
|
||||||
|
# This program is the server side of that exchange, it:
|
||||||
|
# - accepts the PSK-TLS connection,
|
||||||
|
# - hands the card a queue of commands
|
||||||
|
# - and logs the decoded responses.
|
||||||
|
#
|
||||||
|
# The two TS 102 226 annex B figure B.1 administration modes are supported over the
|
||||||
|
# same session, selected with --mode:
|
||||||
|
# ram GP Amendment B RAM:
|
||||||
|
# command is handled by (--targeted-application) a SD
|
||||||
|
# rfm ETSI TS 102 226 RFM/RAM:
|
||||||
|
# command is routed to the Receiving/RFM Application specified by
|
||||||
|
# --targeted-application, for example UICC-filesystem/USIM-ADF RFM app.
|
||||||
|
#
|
||||||
|
# Remote APDU command/response bodies use the Expanded Remote Application
|
||||||
|
# data format.
|
||||||
|
#
|
||||||
|
|
||||||
|
import ssl
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
from osmocom.utils import h2b, b2h
|
||||||
|
|
||||||
|
from pySim.ota import encode_expanded_cmd, decode_expanded_resp
|
||||||
|
|
||||||
|
logger = logging.getLogger(Path(__file__).stem)
|
||||||
|
|
||||||
|
# Amendment B section 3.4
|
||||||
|
ADMIN_PROTOCOL = 'globalplatform-remote-admin/1.0'
|
||||||
|
CT_COMMAND = 'application/vnd.globalplatform.card-content-mgt;version=1.0'
|
||||||
|
CT_RESPONSE = 'application/vnd.globalplatform.card-content-mgt-response;version=1.0'
|
||||||
|
|
||||||
|
# TS 102 226 annex B, figure B.1 RFM/RAM over HTTPS content types
|
||||||
|
CT_RFM_COMMAND = 'application/vnd.etsi.scp.command-data;version=1.0'
|
||||||
|
CT_RFM_RESPONSE = 'application/vnd.etsi.scp.response-data;version=1.0'
|
||||||
|
|
||||||
|
MODE_CONTENT_TYPE = {
|
||||||
|
'ram': CT_COMMAND, # GP Amendment B RAM: target = a Security Domain
|
||||||
|
'rfm': CT_RFM_COMMAND, # ETSI TS 102 226 RFM/RAM: target = an application
|
||||||
|
}
|
||||||
|
|
||||||
|
# Amendment B Table 3-2. The 3DES and NULL suites are left out and can be enabled with
|
||||||
|
# --ciphers / --seclevel.
|
||||||
|
DEFAULT_CIPHERS = ':'.join([
|
||||||
|
'PSK-AES128-CBC-SHA256', # TLS_PSK_WITH_AES_128_CBC_SHA256, TLS 1.2
|
||||||
|
'PSK-AES128-CBC-SHA', # TLS_PSK_WITH_AES_128_CBC_SHA, TLS 1.0/1.1
|
||||||
|
])
|
||||||
|
|
||||||
|
TLS_VERSION_MAP = {
|
||||||
|
'1.0': ssl.TLSVersion.TLSv1,
|
||||||
|
'1.1': ssl.TLSVersion.TLSv1_1,
|
||||||
|
'1.2': ssl.TLSVersion.TLSv1_2,
|
||||||
|
'1.3': ssl.TLSVersion.TLSv1_3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_aid(aid: str) -> str:
|
||||||
|
"""AID -> //aid/<RID>/<PIX> for X-Admin-Targeted-Application from Amendment B section 3.4.2
|
||||||
|
First 5 bytes RID, the PIX the remainder, string in //aid/ notation is passed through."""
|
||||||
|
if aid.startswith('//aid/'):
|
||||||
|
return aid
|
||||||
|
aid = aid.replace(' ', '').lower()
|
||||||
|
if len(aid) < 10:
|
||||||
|
raise ValueError('AID %r is shorter than the 5 byte RID' % aid)
|
||||||
|
rid, pix = aid[:10], aid[10:]
|
||||||
|
return '//aid/%s/%s' % (rid, pix)
|
||||||
|
|
||||||
|
|
||||||
|
def make_ssl_context(psk: bytes, identity: str, *,
|
||||||
|
ciphers: str = DEFAULT_CIPHERS,
|
||||||
|
min_tls: str = '1.2', max_tls: str = '1.3',
|
||||||
|
seclevel: Optional[int] = None,
|
||||||
|
identity_hint: Optional[str] = None,
|
||||||
|
allow_any_identity: bool = False,
|
||||||
|
extra_psks: Optional[Dict[str, bytes]] = None) -> ssl.SSLContext:
|
||||||
|
"""PSK SSLContext, resolvesg the key from the client psk_identity
|
||||||
|
|
||||||
|
extra_psks can carry additional identity->key mappings.
|
||||||
|
allow_any_identity can be used for debugging
|
||||||
|
"""
|
||||||
|
# the PSK callback must return immutable bytes, h2b() gives a bytearray
|
||||||
|
psk = bytes(psk)
|
||||||
|
keymap: Dict[str, bytes] = {identity: psk}
|
||||||
|
if extra_psks:
|
||||||
|
keymap.update({k: bytes(v) for k, v in extra_psks.items()})
|
||||||
|
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.minimum_version = TLS_VERSION_MAP[min_tls]
|
||||||
|
ctx.maximum_version = TLS_VERSION_MAP[max_tls]
|
||||||
|
cipher_str = ciphers
|
||||||
|
if seclevel is not None:
|
||||||
|
# @SECLEVEL=0 is to enable NULL/3DES/legacy PSK suites
|
||||||
|
cipher_str = '%s:@SECLEVEL=%d' % (ciphers, seclevel)
|
||||||
|
if cipher_str:
|
||||||
|
ctx.set_ciphers(cipher_str)
|
||||||
|
|
||||||
|
def psk_server_callback(client_identity: Optional[str]) -> bytes:
|
||||||
|
if allow_any_identity:
|
||||||
|
logger.info('PSK handshake: identity=%r (ACEPTING ANY!)', client_identity)
|
||||||
|
return psk
|
||||||
|
key = keymap.get(client_identity)
|
||||||
|
if key is None:
|
||||||
|
logger.warning('PSK handshake: unknown identity %r (known: %r) -> rejecting',
|
||||||
|
client_identity, list(keymap.keys()))
|
||||||
|
return b'' # empty PSK aborts handshake
|
||||||
|
logger.info('PSK handshake: identity=%r resolved', client_identity)
|
||||||
|
return key
|
||||||
|
|
||||||
|
ctx.set_psk_server_callback(psk_server_callback, identity_hint=identity_hint)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class HttpRequest:
|
||||||
|
"""A parsed HTTP request (request line + headers + body)."""
|
||||||
|
__slots__ = ('method', 'uri', 'version', 'headers', 'body')
|
||||||
|
|
||||||
|
def __init__(self, method: str, uri: str, version: str,
|
||||||
|
headers: Dict[str, str], body: bytes):
|
||||||
|
self.method = method
|
||||||
|
self.uri = uri
|
||||||
|
self.version = version
|
||||||
|
self.headers = headers # lower cased field names
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
def get(self, name: str, default=None) -> Optional[str]:
|
||||||
|
return self.headers.get(name.lower(), default)
|
||||||
|
|
||||||
|
|
||||||
|
# http.client bound on a single HTTP line.
|
||||||
|
MAX_LINE = 65536
|
||||||
|
|
||||||
|
|
||||||
|
def _read_line(rfile) -> bytes:
|
||||||
|
"""readline() with a bound. reaching the bound without a
|
||||||
|
terminator means the card is out of sync somehow, not that the line is long."""
|
||||||
|
line = rfile.readline(MAX_LINE)
|
||||||
|
if line and not line.endswith(b'\n'):
|
||||||
|
raise ValueError('HTTP line longer than %u bytes' % MAX_LINE)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _read_chunked_body(rfile) -> bytes:
|
||||||
|
"""Read a Transfer-Encoding: chunked body. Amendment B section 3.4.1 lets
|
||||||
|
the card send its response string with either a Content-Length or chunked"""
|
||||||
|
out = bytearray()
|
||||||
|
while True:
|
||||||
|
size_line = _read_line(rfile)
|
||||||
|
if not size_line:
|
||||||
|
break
|
||||||
|
size = int(size_line.split(b';', 1)[0].strip() or b'0', 16)
|
||||||
|
if size == 0:
|
||||||
|
# consume trailer headers up to the terminating blank line
|
||||||
|
while _read_line(rfile) not in (b'\r\n', b'\n', b''):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
chunk = rfile.read(size)
|
||||||
|
if len(chunk) != size:
|
||||||
|
raise ValueError('chunked body ended after %u of %u bytes' % (len(chunk), size))
|
||||||
|
out += chunk
|
||||||
|
_read_line(rfile) # trailing CRLF after the chunk data
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def read_http_request(rfile, send: Optional[Callable[[bytes], None]] = None) -> Optional[HttpRequest]:
|
||||||
|
"""Read one HTTP request from a buffered binary reader or None when closed"""
|
||||||
|
request_line = _read_line(rfile)
|
||||||
|
if not request_line:
|
||||||
|
return None
|
||||||
|
parts = request_line.rstrip(b'\r\n').decode('iso-8859-1').split(' ')
|
||||||
|
if len(parts) < 3:
|
||||||
|
raise ValueError('Malformed HTTP request line: %r' % request_line)
|
||||||
|
method, uri, version = parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
|
headers: Dict[str, str] = {}
|
||||||
|
while True:
|
||||||
|
line = _read_line(rfile)
|
||||||
|
if line in (b'\r\n', b'\n', b''):
|
||||||
|
break
|
||||||
|
name, _, value = line.rstrip(b'\r\n').decode('iso-8859-1').partition(':')
|
||||||
|
headers[name.strip().lower()] = value.strip()
|
||||||
|
|
||||||
|
# Expect: 100-continue waits for the response before it sends the body,
|
||||||
|
# and RFC 2616 8.2.3 (Amendment B references RFC 2616 as [HTTP])
|
||||||
|
# requires the server to send it. Amendment B 3.4.1 does not mention this
|
||||||
|
# header, tho, might be useless.
|
||||||
|
if send and '100-continue' in headers.get('expect', '').lower():
|
||||||
|
logger.info('-> 100 Continue ')
|
||||||
|
send(b'HTTP/1.1 100 Continue\r\n\r\n')
|
||||||
|
|
||||||
|
body = b''
|
||||||
|
te = headers.get('transfer-encoding', '').lower()
|
||||||
|
if 'chunked' in te:
|
||||||
|
body = _read_chunked_body(rfile)
|
||||||
|
elif 'content-length' in headers:
|
||||||
|
n = int(headers['content-length'])
|
||||||
|
if n:
|
||||||
|
body = rfile.read(n)
|
||||||
|
return HttpRequest(method, uri, version, headers, body)
|
||||||
|
|
||||||
|
|
||||||
|
def build_http_response(status_line: str, headers: List[Tuple[str, str]],
|
||||||
|
body: bytes = b'') -> bytes:
|
||||||
|
"""Serialise HTTP response. status_line 'HTTP/1.1 200 OK'."""
|
||||||
|
lines = [status_line]
|
||||||
|
lines += ['%s: %s' % (name, value) for name, value in headers]
|
||||||
|
head = ('\r\n'.join(lines) + '\r\n\r\n').encode('iso-8859-1')
|
||||||
|
return head + body
|
||||||
|
|
||||||
|
|
||||||
|
def format_decoded_response(dec) -> str:
|
||||||
|
"""hand over the data"""
|
||||||
|
bits = ['%u command(s) executed' % dec.number_of_commands]
|
||||||
|
for i, c in enumerate(dec.commands):
|
||||||
|
data = c.response_data or '-'
|
||||||
|
bits.append(' R-APDU[%u]: SW=%s data=%s' % (i, c.status_word, data))
|
||||||
|
if dec.get('truncated'):
|
||||||
|
bits.append(' TRUNCATED: an R-APDU returned SW 62F1, so the card cut the response data '
|
||||||
|
'short and stopped executing the rest of the script ') # TS 102 226 5.2.1.1
|
||||||
|
if dec.bad_format is not None:
|
||||||
|
bits.append(' bad-format: %s' % dec.bad_format)
|
||||||
|
if dec.immediate_action_response is not None:
|
||||||
|
bits.append(' immediate-action-response: %s' % dec.immediate_action_response)
|
||||||
|
if dec.script_chaining_response is not None:
|
||||||
|
bits.append(' script-chaining-response: %s' % dec.script_chaining_response)
|
||||||
|
return '\n'.join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSession:
|
||||||
|
|
||||||
|
def __init__(self, command_bodies: List[bytes],
|
||||||
|
next_uri: Optional[str] = None,
|
||||||
|
targeted_application: Optional[str] = None,
|
||||||
|
on_response: Optional[Callable[[object], None]] = None,
|
||||||
|
content_type: str = CT_COMMAND):
|
||||||
|
self.pending: List[bytes] = list(command_bodies)
|
||||||
|
self.next_uri = next_uri # None -> echo the request URI
|
||||||
|
self.targeted_application = targeted_application
|
||||||
|
self.on_response = on_response
|
||||||
|
self.content_type = content_type # Content-Type for the command body
|
||||||
|
self.responses: List[object] = [] # decoded Containers, in order
|
||||||
|
|
||||||
|
def record_response(self, dec) -> None:
|
||||||
|
self.responses.append(dec)
|
||||||
|
if self.on_response:
|
||||||
|
self.on_response(dec)
|
||||||
|
|
||||||
|
|
||||||
|
def run_admin_loop(rfile, send: Callable[[bytes], None], session: AdminSession) -> AdminSession:
|
||||||
|
"""Drive the admin loop for one connection.
|
||||||
|
Just keep answering the card POST requests with the next queued command
|
||||||
|
(200 OK + Expanded command body) until the queue is empty, end session with 204 No Content."""
|
||||||
|
while True:
|
||||||
|
req = read_http_request(rfile, send)
|
||||||
|
if req is None:
|
||||||
|
logger.info('connection closed by card')
|
||||||
|
return session
|
||||||
|
|
||||||
|
if req.method != 'POST':
|
||||||
|
logger.warning('unexpected method %s %s -> 405', req.method, req.uri)
|
||||||
|
send(build_http_response('HTTP/1.1 405 Method Not Allowed',
|
||||||
|
[('X-Admin-Protocol', ADMIN_PROTOCOL),
|
||||||
|
('Connection', 'close')]))
|
||||||
|
return session
|
||||||
|
|
||||||
|
proto = req.get('x-admin-protocol')
|
||||||
|
if proto and proto != ADMIN_PROTOCOL:
|
||||||
|
logger.warning('card X-Admin-Protocol=%r (expected %r)', proto, ADMIN_PROTOCOL)
|
||||||
|
status = req.get('x-admin-script-status')
|
||||||
|
resume = req.get('x-admin-resume')
|
||||||
|
logger.info('POST %s from=%r status=%r resume=%r body=%uB',
|
||||||
|
req.uri, req.get('x-admin-from'), status, resume, len(req.body))
|
||||||
|
|
||||||
|
# section 3.4.1:
|
||||||
|
# - body with "X-Admin-Script-Status: ok" carries the previous command
|
||||||
|
# response string (Expanded Remote response format);
|
||||||
|
# - other status values carry no body ().
|
||||||
|
if req.body:
|
||||||
|
# Expanded Remote response:
|
||||||
|
# - GP Amd B 'card-content-mgt-response'
|
||||||
|
# - ETSI 'scp.response-data'
|
||||||
|
logger.debug(' response Content-Type=%r raw body (%uB): %s',
|
||||||
|
req.get('content-type'), len(req.body), b2h(req.body))
|
||||||
|
if status in (None, 'ok'):
|
||||||
|
try:
|
||||||
|
dec = decode_expanded_resp(req.body)
|
||||||
|
session.record_response(dec)
|
||||||
|
logger.info('card response:\n%s', format_decoded_response(dec))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('failed to decode response body %s: %s', b2h(req.body), e)
|
||||||
|
else:
|
||||||
|
logger.warning('body present with status=%r; ignoring', status) # section 3.4.1
|
||||||
|
elif status and status != 'ok':
|
||||||
|
logger.info('card reported script-status=%r (no response body)', status)
|
||||||
|
|
||||||
|
if session.pending:
|
||||||
|
body = session.pending.pop(0)
|
||||||
|
next_uri = session.next_uri or req.uri
|
||||||
|
headers = [('X-Admin-Protocol', ADMIN_PROTOCOL),
|
||||||
|
('X-Admin-Next-URI', next_uri),
|
||||||
|
('Content-Type', session.content_type)]
|
||||||
|
if session.targeted_application:
|
||||||
|
headers.append(('X-Admin-Targeted-Application', session.targeted_application))
|
||||||
|
headers.append(('Content-Length', str(len(body))))
|
||||||
|
logger.info('-> 200 OK, next command (%uB): %s', len(body), b2h(body))
|
||||||
|
send(build_http_response('HTTP/1.1 200 OK', headers, body))
|
||||||
|
else:
|
||||||
|
# section 3.4.2: No more commands, end session
|
||||||
|
# No Content-Type or body for 204
|
||||||
|
logger.info('-> 204 No Content, ending administration session')
|
||||||
|
send(build_http_response('HTTP/1.1 204 No Content',
|
||||||
|
[('X-Admin-Protocol', ADMIN_PROTOCOL),
|
||||||
|
('Connection', 'close')]))
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class Scp81AdminServer:
|
||||||
|
"""Threaded TLS-PSK server that runs the Amendment B admin loop against each
|
||||||
|
connecting card."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int, ssl_ctx: ssl.SSLContext,
|
||||||
|
command_bodies: List[bytes],
|
||||||
|
next_uri: Optional[str] = None,
|
||||||
|
targeted_application: Optional[str] = None,
|
||||||
|
on_response: Optional[Callable[[object], None]] = None,
|
||||||
|
on_session_end: Optional[Callable[[AdminSession], None]] = None,
|
||||||
|
content_type: str = CT_COMMAND):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.ssl_ctx = ssl_ctx
|
||||||
|
self.command_bodies = command_bodies
|
||||||
|
self.next_uri = next_uri
|
||||||
|
self.targeted_application = targeted_application
|
||||||
|
self.content_type = content_type
|
||||||
|
self.on_response = on_response
|
||||||
|
self.on_session_end = on_session_end
|
||||||
|
self._sock: Optional[socket.socket] = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def bind(self) -> int:
|
||||||
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
self._sock.bind((self.host, self.port))
|
||||||
|
self._sock.listen(5)
|
||||||
|
self._sock.settimeout(0.5)
|
||||||
|
self.port = self._sock.getsockname()[1]
|
||||||
|
return self.port
|
||||||
|
|
||||||
|
def serve_forever(self) -> None:
|
||||||
|
if self._sock is None:
|
||||||
|
self.bind()
|
||||||
|
logger.info('SCP81 admin server listening on %s:%u (%u command(s) queued)',
|
||||||
|
self.host, self.port, len(self.command_bodies))
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
conn, addr = self._sock.accept()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
threading.Thread(target=self._handle, args=(conn, addr), daemon=True).start()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._sock is not None:
|
||||||
|
self._sock.close()
|
||||||
|
|
||||||
|
def _handle(self, conn: socket.socket, addr) -> None:
|
||||||
|
try:
|
||||||
|
tls = self.ssl_ctx.wrap_socket(conn, server_side=True)
|
||||||
|
except (ssl.SSLError, OSError) as e:
|
||||||
|
logger.warning('TLS-PSK handshake with %s failed: %s', addr, e)
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
logger.info('TLS-PSK established with %s: %s / %s', addr, tls.version(), tls.cipher())
|
||||||
|
session = AdminSession(self.command_bodies, next_uri=self.next_uri,
|
||||||
|
targeted_application=self.targeted_application,
|
||||||
|
on_response=self.on_response,
|
||||||
|
content_type=self.content_type)
|
||||||
|
try:
|
||||||
|
rfile = tls.makefile('rb')
|
||||||
|
run_admin_loop(rfile, tls.sendall, session)
|
||||||
|
except (ssl.SSLError, OSError, ValueError) as e:
|
||||||
|
logger.warning('session with %s aborted: %s', addr, e)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
tls.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
logger.info('session with %s ended: %u response(s) collected', addr, len(session.responses))
|
||||||
|
if self.on_session_end:
|
||||||
|
self.on_session_end(session)
|
||||||
|
|
||||||
|
|
||||||
|
def build_command_bodies(apdus: List[bytes], batch: bool = False,
|
||||||
|
length_coding: str = 'definite') -> List[bytes]:
|
||||||
|
"""wrpa apdus
|
||||||
|
each C-APDU -> one cmd message + one HTTP response per APDU
|
||||||
|
batch=True -> all C-APDUs in one Command Scripting template.
|
||||||
|
length_coding selects the definite or indefinite Command Scripting template."""
|
||||||
|
if not apdus:
|
||||||
|
return []
|
||||||
|
if batch:
|
||||||
|
return [encode_expanded_cmd(apdus, length_coding=length_coding)]
|
||||||
|
return [encode_expanded_cmd(a, length_coding=length_coding) for a in apdus]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='TLS-PSK HTTP Remote Administration Server for SCP81 / RAM over HTTP')
|
||||||
|
parser.add_argument('--host', default='0.0.0.0', help='Host/IP to bind to (default: 0.0.0.0)')
|
||||||
|
parser.add_argument('--port', type=int, default=8443, help='TCP port to bind to (default: 8443)')
|
||||||
|
parser.add_argument('--psk', required=True,
|
||||||
|
help='PSK TLS key, Amendment B key type 85 as hex')
|
||||||
|
parser.add_argument('--psk-identity', required=True,
|
||||||
|
help='Expected PSK identity string presented by the card')
|
||||||
|
parser.add_argument('--psk-identity-hint', default=None,
|
||||||
|
help='Optional PSK identity hint to send to the card (default: none)')
|
||||||
|
parser.add_argument('--allow-any-identity', action='store_true',
|
||||||
|
help='DEBUG: Accept any psk_identity')
|
||||||
|
parser.add_argument('--ciphers', default=DEFAULT_CIPHERS,
|
||||||
|
help='OpenSSL cipher string for TLS<=1.2')
|
||||||
|
parser.add_argument('--min-tls', default='1.2', choices=sorted(TLS_VERSION_MAP),
|
||||||
|
help='Minimum TLS version (default: 1.2)')
|
||||||
|
parser.add_argument('--max-tls', default='1.3', choices=sorted(TLS_VERSION_MAP),
|
||||||
|
help='Maximum TLS version (default: 1.3)')
|
||||||
|
parser.add_argument('--seclevel', type=int, default=None,
|
||||||
|
help='OpenSSL @SECLEVEL to force (0 to enable NULL/3DES/legacy PSK)')
|
||||||
|
parser.add_argument('--uri', default=None,
|
||||||
|
help='X-Admin-Next-URI to hand the card (default: request URI)')
|
||||||
|
parser.add_argument('--mode', choices=sorted(MODE_CONTENT_TYPE), default='ram',
|
||||||
|
help='"ram" = GP Amendment B RAM to a SD (default), '
|
||||||
|
'"rfm" = TS 102 226 RFM/RAM to the --targeted-application.')
|
||||||
|
parser.add_argument('--targeted-application', default=None,
|
||||||
|
help='X-Admin-Targeted-Application AID (hex). '
|
||||||
|
'Required by --mode rfm, optional for --mode ram')
|
||||||
|
parser.add_argument('--length-coding', choices=('definite', 'indefinite'), default='definite',
|
||||||
|
help='Expanded format length coding "definite" "indefinite"')
|
||||||
|
parser.add_argument('--apdu', action='append', default=[], metavar='HEX',
|
||||||
|
help='one of many C-APDU (hex) to send, executed in order')
|
||||||
|
parser.add_argument('--apdu-file', default=None,
|
||||||
|
help='File with one C-APDU (hex) per line to push (# comments allowed)')
|
||||||
|
parser.add_argument('--batch', action='store_true',
|
||||||
|
help='All C-APDUs in one large command message')
|
||||||
|
parser.add_argument('--raw-cmd', action='append', default=[], metavar='HEX',
|
||||||
|
help='Debug, raw command')
|
||||||
|
parser.add_argument('-v', '--verbose', action='store_true', help='enable debug output')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
if args.mode == 'rfm' and not args.targeted_application:
|
||||||
|
parser.error('--mode rfm requires --targeted-application <RFM Application AID>')
|
||||||
|
content_type = MODE_CONTENT_TYPE[args.mode]
|
||||||
|
|
||||||
|
apdus: List[bytes] = [h2b(a) for a in args.apdu]
|
||||||
|
if args.apdu_file:
|
||||||
|
for line in Path(args.apdu_file).read_text().splitlines():
|
||||||
|
line = line.split('#', 1)[0].strip()
|
||||||
|
if line:
|
||||||
|
apdus.append(h2b(line))
|
||||||
|
command_bodies = build_command_bodies(apdus, batch=args.batch,
|
||||||
|
length_coding=args.length_coding)
|
||||||
|
command_bodies += [h2b(r) for r in args.raw_cmd]
|
||||||
|
if not command_bodies:
|
||||||
|
logger.warning('no C-APDUs: the server will answer the first POST with 204...')
|
||||||
|
|
||||||
|
targeted = format_aid(args.targeted_application) if args.targeted_application else None
|
||||||
|
logger.info('mode=%s content-type=%s length-coding=%s targeted-application=%s',
|
||||||
|
args.mode, content_type, args.length_coding, targeted or '(none)')
|
||||||
|
|
||||||
|
ssl_ctx = make_ssl_context(h2b(args.psk), args.psk_identity,
|
||||||
|
ciphers=args.ciphers,
|
||||||
|
min_tls=args.min_tls, max_tls=args.max_tls,
|
||||||
|
seclevel=args.seclevel,
|
||||||
|
identity_hint=args.psk_identity_hint,
|
||||||
|
allow_any_identity=args.allow_any_identity)
|
||||||
|
|
||||||
|
server = Scp81AdminServer(args.host, args.port, ssl_ctx, command_bodies,
|
||||||
|
next_uri=args.uri, targeted_application=targeted,
|
||||||
|
content_type=content_type)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info('shutting down')
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Executable
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""scp81_trigger.py -- build the OTA packet that asks the card to open an SCP81 admin session."""
|
||||||
|
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Eric Wild <ewild@sysmocom.de>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
# Prints the apdu line for AdmSessTriggerParams TLV as the sms secured data, Expanded RFM mode,
|
||||||
|
# to be fed into pysim_shell.py
|
||||||
|
#
|
||||||
|
# security params supplied either
|
||||||
|
# - in the trigger
|
||||||
|
# - from the cards data object,
|
||||||
|
# trigger wins when both are present.
|
||||||
|
# --no-sec omits them from the trigger so the stored ones are used.
|
||||||
|
#
|
||||||
|
# example params:
|
||||||
|
# --psk-id 'PSK Identity 123' --kvn 0x41 --kid-ref 5
|
||||||
|
# --ip 127.0.0.1 --port 8080 --buffer 512
|
||||||
|
# --host 172.96.0.1 --uri '/server/adminagent?cmd=1'
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from osmocom.utils import b2h # noqa: E402
|
||||||
|
from pySim.cat import (sms_pp_download_envelope, BearerDescription, # noqa: E402
|
||||||
|
BufferSize, UiccTransportLevel, OtherAddress)
|
||||||
|
from pySim.global_platform.http import (AdmSessTriggerParams, AdmSessionParams, # noqa: E402
|
||||||
|
SecurityParams, HttpPostParams, RasConnectionParams,
|
||||||
|
AdminHostParam, AdminUriParam)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--psk-id", help="PSK identity for ClientHello (required, unless --no-sec)")
|
||||||
|
ap.add_argument("--kvn", type=lambda s: int(s, 0), help="key version of the PSK (required, unless --no-sec)")
|
||||||
|
ap.add_argument("--kid-ref", type=lambda s: int(s, 0), help="PSK key id (required, unless --no-sec)")
|
||||||
|
ap.add_argument("--host", help="HTTP Host header (required, unless --no-http)")
|
||||||
|
ap.add_argument("--uri", help="HTTP request URI (required, unless --no-http)")
|
||||||
|
ap.add_argument("--ip", help="administration server address, BIP (required, unless --no-conn)")
|
||||||
|
ap.add_argument("--port", type=int, help="administration server port (required, unless --no-conn)")
|
||||||
|
ap.add_argument("--buffer", type=int, help="BIP buffer size (required, unless --no-conn)")
|
||||||
|
ap.add_argument("--no-conn", action="store_true", help="omit the connection params (tag 0x84)")
|
||||||
|
ap.add_argument("--no-sec", action="store_true", help="omit the security params (tag 0x85)")
|
||||||
|
ap.add_argument("--no-http", action="store_true", help="omit the HTTP POST params (tag 0x89)")
|
||||||
|
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
if not args.no_conn:
|
||||||
|
missing += [n for n in ('ip', 'port', 'buffer') if getattr(args, n) is None]
|
||||||
|
if not args.no_sec:
|
||||||
|
missing += [n for n in ('psk_id', 'kvn', 'kid_ref') if getattr(args, n) is None]
|
||||||
|
if not args.no_http:
|
||||||
|
missing += [n for n in ('host', 'uri') if getattr(args, n) is None]
|
||||||
|
if missing:
|
||||||
|
ap.error("pass every value required: %s." % " ".join("--" + n.replace('_', '-') for n in missing))
|
||||||
|
|
||||||
|
session = []
|
||||||
|
if not args.no_conn:
|
||||||
|
session.append(RasConnectionParams(children=[
|
||||||
|
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': ''}),
|
||||||
|
BufferSize(decoded=args.buffer),
|
||||||
|
UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote',
|
||||||
|
'port_number': args.port}),
|
||||||
|
OtherAddress(decoded={'type_of_address': 'ipv4',
|
||||||
|
'address': bytes(int(b) for b in args.ip.split("."))})]))
|
||||||
|
if not args.no_sec:
|
||||||
|
session.append(SecurityParams(decoded={'psk_id': args.psk_id.encode(), 'kvn': args.kvn,
|
||||||
|
'kid': args.kid_ref, 'sha_type': None}))
|
||||||
|
if not args.no_http:
|
||||||
|
session.append(HttpPostParams(children=[AdminHostParam(decoded=args.host),
|
||||||
|
AdminUriParam(decoded=args.uri)]))
|
||||||
|
trig = AdmSessTriggerParams(children=[AdmSessionParams(children=session)]).to_tlv()
|
||||||
|
|
||||||
|
# stderr for logs, stdout for data
|
||||||
|
print("# trigger TLV %d B %s" % (len(trig), trig.hex()), file=sys.stderr)
|
||||||
|
print("# %-13s %d B %s" % ("secured data", len(trig), trig.hex()), file=sys.stderr)
|
||||||
|
print(trig.hex())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+298
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Harald Welte, Philipp Maier
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import smpplib.gsm
|
||||||
|
import smpplib.client
|
||||||
|
import smpplib.consts
|
||||||
|
import time
|
||||||
|
from pySim.ota import OtaKeyset, OtaDialectSms, OtaAlgoCrypt, OtaAlgoAuth, OtaCheckError, CNTR_REQ, RC_CC_DS, POR_REQ
|
||||||
|
from pySim.sms import ConcatenatedSmsReassembler
|
||||||
|
from pySim.utils import b2h, h2b, is_hexstr
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(Path(__file__).stem)
|
||||||
|
|
||||||
|
option_parser = argparse.ArgumentParser(description='Tool to send OTA SMS RFM/RAM messages via SMPP',
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||||
|
option_parser.add_argument("--host", help="Host/IP of the SMPP server", default="localhost")
|
||||||
|
option_parser.add_argument("--port", help="TCP port of the SMPP server", default=2775, type=int)
|
||||||
|
option_parser.add_argument("--system-id", help="System ID to use to bind to the SMPP server", default="test")
|
||||||
|
option_parser.add_argument("--password", help="Password to use to bind to the SMPP server", default="test")
|
||||||
|
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
|
||||||
|
algo_crypt_choices = []
|
||||||
|
algo_crypt_classes = OtaAlgoCrypt.__subclasses__()
|
||||||
|
for cls in algo_crypt_classes:
|
||||||
|
algo_crypt_choices.append(cls.enum_name)
|
||||||
|
option_parser.add_argument("--algo-crypt", choices=algo_crypt_choices, default='triple_des_cbc2',
|
||||||
|
help="OTA crypt algorithm")
|
||||||
|
algo_auth_choices = []
|
||||||
|
algo_auth_classes = OtaAlgoAuth.__subclasses__()
|
||||||
|
for cls in algo_auth_classes:
|
||||||
|
algo_auth_choices.append(cls.enum_name)
|
||||||
|
option_parser.add_argument("--algo-auth", choices=algo_auth_choices, default='triple_des_cbc2',
|
||||||
|
help="OTA auth algorithm")
|
||||||
|
option_parser.add_argument('--kic', required=True, type=is_hexstr, help='OTA key (KIC)')
|
||||||
|
option_parser.add_argument('--kic-idx', default=1, type=int, help='OTA key index (KIC)')
|
||||||
|
option_parser.add_argument('--kid', required=True, type=is_hexstr, help='OTA key (KID)')
|
||||||
|
option_parser.add_argument('--kid-idx', default=1, type=int, help='OTA key index (KID)')
|
||||||
|
option_parser.add_argument('--cntr', default=0, type=int, help='replay protection counter')
|
||||||
|
option_parser.add_argument('--tar', required=True, type=is_hexstr, help='Toolkit Application Reference')
|
||||||
|
option_parser.add_argument("--cntr-req", choices=CNTR_REQ.decmapping.values(), default='no_counter',
|
||||||
|
help="Counter requirement")
|
||||||
|
option_parser.add_argument('--no-ciphering', action='store_true', default=False, help='Disable ciphering')
|
||||||
|
option_parser.add_argument("--rc-cc-ds", choices=RC_CC_DS.decmapping.values(), default='cc',
|
||||||
|
help="message check (rc=redundency check, cc=crypt. checksum, ds=digital signature)")
|
||||||
|
option_parser.add_argument('--por-in-submit', action='store_true', default=False,
|
||||||
|
help='require PoR to be sent via SMS-SUBMIT')
|
||||||
|
option_parser.add_argument('--por-no-ciphering', action='store_true', default=False, help='Disable ciphering (PoR)')
|
||||||
|
option_parser.add_argument("--por-rc-cc-ds", choices=RC_CC_DS.decmapping.values(), default='cc',
|
||||||
|
help="PoR check (rc=redundency check, cc=crypt. checksum, ds=digital signature)")
|
||||||
|
option_parser.add_argument("--por-req", choices=POR_REQ.decmapping.values(), default='por_required',
|
||||||
|
help="Proof of Receipt requirements")
|
||||||
|
option_parser.add_argument('--src-addr', default='12', type=str, help='SMS source address (MSISDN)')
|
||||||
|
option_parser.add_argument('--dest-addr', default='23', type=str, help='SMS destination address (MSISDN)')
|
||||||
|
option_parser.add_argument('--timeout', default=10, type=int, help='Maximum response waiting time')
|
||||||
|
option_parser.add_argument('--format', choices=['compact', 'expanded'], default='compact',
|
||||||
|
help="Remote Application data format: 'compact' or 'expanded'")
|
||||||
|
option_parser.add_argument('-a', '--apdu', action='append', required=True, type=is_hexstr, help='C-APDU to send')
|
||||||
|
|
||||||
|
class SmppHandler:
|
||||||
|
client = None
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int,
|
||||||
|
system_id: str, password: str,
|
||||||
|
ota_keyset: OtaKeyset, spi: dict, tar: bytes,
|
||||||
|
remote_format: str = 'compact'):
|
||||||
|
"""
|
||||||
|
Initialize connection to SMPP server and set static OTA SMS-TPDU ciphering parameters
|
||||||
|
Args:
|
||||||
|
host : Hostname or IPv4/IPv6 address of the SMPP server
|
||||||
|
port : TCP Port of the SMPP server
|
||||||
|
system_id: SMPP System-ID used by ESME (client) to bind
|
||||||
|
password: SMPP Password used by ESME (client) to bind
|
||||||
|
ota_keyset: OTA keyset to be used for SMS-TPDU ciphering
|
||||||
|
spi: Security Parameter Indicator (SPI) to be used for SMS-TPDU ciphering
|
||||||
|
tar: Toolkit Application Reference (TAR) of the targeted card application
|
||||||
|
remote_format: Remote Application data format ('compact' or 'expanded', TS 102 226)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create and connect SMPP client
|
||||||
|
client = smpplib.client.Client(host, port, allow_unknown_opt_params=True)
|
||||||
|
client.set_message_sent_handler(self.message_sent_handler)
|
||||||
|
client.set_message_received_handler(self.message_received_handler)
|
||||||
|
client.connect()
|
||||||
|
client.bind_transceiver(system_id=system_id, password=password)
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
# Setup static OTA parameters
|
||||||
|
self.ota_dialect = OtaDialectSms()
|
||||||
|
self.ota_keyset = ota_keyset
|
||||||
|
self.tar = tar
|
||||||
|
self.spi = spi
|
||||||
|
self.remote_format = remote_format
|
||||||
|
self.reassembler = ConcatenatedSmsReassembler()
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if self.client:
|
||||||
|
self.client.unbind()
|
||||||
|
self.client.disconnect()
|
||||||
|
|
||||||
|
def _decode_resp(self, tpud: bytes) -> tuple:
|
||||||
|
"""Decode a response SMS-TPDU into (response_packet, decoded).
|
||||||
|
|
||||||
|
Retry to decoding with ciphering disabled (in case the card has problems to decode the SMS-TDPU
|
||||||
|
we have sent, the response will contain an unencrypted error message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.ota_dialect.decode_resp(self.ota_keyset, self.spi, tpud,
|
||||||
|
remote_format=self.remote_format)
|
||||||
|
except (ValueError, OtaCheckError):
|
||||||
|
spi = self.spi.copy()
|
||||||
|
spi['por_shall_be_ciphered'] = False
|
||||||
|
spi['por_rc_cc_ds'] = 'no_rc_cc_ds'
|
||||||
|
return self.ota_dialect.decode_resp(self.ota_keyset, spi, tpud,
|
||||||
|
remote_format=self.remote_format)
|
||||||
|
|
||||||
|
def message_received_handler(self, pdu):
|
||||||
|
if not pdu.short_message:
|
||||||
|
return None
|
||||||
|
logger.info("SMS-TPDU received: %s", b2h(pdu.short_message))
|
||||||
|
tpud = self.reassembler.add(pdu.short_message)
|
||||||
|
if tpud is None:
|
||||||
|
logger.info("SMS-TPDU is part of concat message, waiting for more parts...")
|
||||||
|
return None
|
||||||
|
if tpud != pdu.short_message:
|
||||||
|
logger.info("SMS-TPDU reassembled: %s", b2h(tpud))
|
||||||
|
try:
|
||||||
|
res, decoded = self._decode_resp(tpud)
|
||||||
|
except Exception as e:
|
||||||
|
# for example ENVELOPE POR
|
||||||
|
logger.warning("Ignoring undecodable resp SMS-TPDU (%s: %s)", type(e).__name__, e)
|
||||||
|
return None
|
||||||
|
logger.info("SMS-TPDU decoded: %s", (res, decoded))
|
||||||
|
# large app response as reassembled SEND SHORT MESSAGE, but
|
||||||
|
# the ENVELOPE itself returns a POR without R-APDU.
|
||||||
|
# smpplib poll() drains all pending SMS in one call, so that PoR is processed
|
||||||
|
# right after the real response and would overwrite it,
|
||||||
|
# which leaves transceive_apdu with no last_response_data to return.
|
||||||
|
# Only allow a response that has no application data (decoded == None)
|
||||||
|
# if we do not already have a real one.
|
||||||
|
if decoded is None and self.response is not None and self.response[1] is not None:
|
||||||
|
logger.info("ignoring status response to keep earlier app response")
|
||||||
|
return None
|
||||||
|
self.response = (res, decoded)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def message_sent_handler(self, pdu):
|
||||||
|
logger.debug("SMS-TPDU sent: pdu_sequence=%s pdu_message_id=%s", pdu.sequence, pdu.message_id)
|
||||||
|
|
||||||
|
def transceive_sms_tpdu(self, tpdu: bytes, src_addr: str, dest_addr: str, timeout: int) -> tuple:
|
||||||
|
"""
|
||||||
|
Transceive SMS-TPDU. This method sends the SMS-TPDU to the SMPP server, and waits for a response. The method
|
||||||
|
returns when the response is received.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tpdu : short message content (plaintext)
|
||||||
|
src_addr : short message source address
|
||||||
|
dest_addr : short message destination address
|
||||||
|
timeout : timeout after which this method should give up waiting for a response
|
||||||
|
Returns:
|
||||||
|
tuple containing the response (plaintext)
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.info("SMS-TPDU sending: %s...", b2h(tpdu))
|
||||||
|
|
||||||
|
self.client.send_message(
|
||||||
|
# TODO: add parameters to switch source_addr_ton and dest_addr_ton between SMPP_TON_INTL and SMPP_NPI_ISDN
|
||||||
|
source_addr_ton=smpplib.consts.SMPP_TON_INTL,
|
||||||
|
source_addr=src_addr,
|
||||||
|
dest_addr_ton=smpplib.consts.SMPP_TON_INTL,
|
||||||
|
destination_addr=dest_addr,
|
||||||
|
short_message=tpdu,
|
||||||
|
# TODO: add parameters to set data_coding and esm_class
|
||||||
|
data_coding=smpplib.consts.SMPP_ENCODING_BINARY,
|
||||||
|
esm_class=smpplib.consts.SMPP_GSMFEAT_UDHI,
|
||||||
|
protocol_id=0x7f,
|
||||||
|
# TODO: add parameter to use registered delivery
|
||||||
|
# registered_delivery=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SMS-TPDU sent, waiting for response...")
|
||||||
|
timestamp_sent=int(time.time())
|
||||||
|
self.response = None
|
||||||
|
while self.response is None:
|
||||||
|
self.client.poll()
|
||||||
|
if int(time.time()) - timestamp_sent > timeout:
|
||||||
|
raise ValueError("Timeout reached, no response SMS-TPDU received!")
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def transceive_apdu(self, apdu: bytes, src_addr: str, dest_addr: str, timeout: int) -> tuple[bytes, bytes]:
|
||||||
|
"""
|
||||||
|
Transceive APDU. This method wraps the given APDU into an SMS-TPDU, sends it to the SMPP server and waits for
|
||||||
|
the response. When the response is received, the last response data and the last status word is extracted from
|
||||||
|
the response and returned to the caller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
apdu : one or more concatenated APDUs
|
||||||
|
src_addr : short message source address
|
||||||
|
dest_addr : short message destination address
|
||||||
|
timeout : timeout after which this method should give up waiting for a response
|
||||||
|
Returns:
|
||||||
|
tuple containing the last response data and the last status word as byte strings
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(apdu, (list, tuple)):
|
||||||
|
logger.info("C-APDU(s) sending: %s...", [b2h(a) for a in apdu])
|
||||||
|
else:
|
||||||
|
logger.info("C-APDU sending: %s...", b2h(apdu))
|
||||||
|
|
||||||
|
# translate to Secured OTA RFM
|
||||||
|
secured = self.ota_dialect.encode_cmd(self.ota_keyset, self.tar, self.spi, apdu=apdu,
|
||||||
|
remote_format=self.remote_format)
|
||||||
|
# add user data header
|
||||||
|
tpdu = b'\x02\x70\x00' + secured
|
||||||
|
# send via SMPP
|
||||||
|
response = self.transceive_sms_tpdu(tpdu, src_addr, dest_addr, timeout)
|
||||||
|
|
||||||
|
# Extract last_response_data and last_status_word from the response
|
||||||
|
sw = None
|
||||||
|
resp = None
|
||||||
|
for container in response:
|
||||||
|
if container:
|
||||||
|
container_dict = dict(container)
|
||||||
|
resp = container_dict.get('last_response_data')
|
||||||
|
sw = container_dict.get('last_status_word')
|
||||||
|
# expanded format: decoded response carries
|
||||||
|
# per command R-APDU list; log each one.
|
||||||
|
for i, cmd in enumerate(container_dict.get('commands') or []):
|
||||||
|
logger.info("R-APDU[%u] received: %s %s", i,
|
||||||
|
cmd['response_data'], cmd['status_word'])
|
||||||
|
if container_dict.get('truncated'):
|
||||||
|
logger.warning("Response was TRUNCATED (SW 62F1): the card cut the response "
|
||||||
|
"data short and did not execute the rest of the script")
|
||||||
|
if container_dict.get('bad_format') is not None:
|
||||||
|
logger.warning("Response contains a Bad format TLV: %s",
|
||||||
|
container_dict['bad_format'])
|
||||||
|
if resp is None:
|
||||||
|
raise ValueError("Response does not contain any last_response_data, no R-APDU received!")
|
||||||
|
if sw is None:
|
||||||
|
raise ValueError("Response does not contain any last_status_word, no R-APDU received!")
|
||||||
|
|
||||||
|
logger.info("R-APDU received: %s %s", resp, sw)
|
||||||
|
return h2b(resp), h2b(sw)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
opts = option_parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG if opts.verbose else logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
if opts.kic_idx != opts.kid_idx:
|
||||||
|
logger.warning("KIC index (%s) and KID index (%s) are different (security violation, card should reject message)",
|
||||||
|
opts.kic_idx, opts.kid_idx)
|
||||||
|
|
||||||
|
ota_keyset = OtaKeyset(algo_crypt=opts.algo_crypt,
|
||||||
|
kic_idx=opts.kic_idx,
|
||||||
|
kic=h2b(opts.kic),
|
||||||
|
algo_auth=opts.algo_auth,
|
||||||
|
kid_idx=opts.kid_idx,
|
||||||
|
kid=h2b(opts.kid),
|
||||||
|
cntr=opts.cntr)
|
||||||
|
spi = {'counter' : opts.cntr_req,
|
||||||
|
'ciphering' : not opts.no_ciphering,
|
||||||
|
'rc_cc_ds': opts.rc_cc_ds,
|
||||||
|
'por_in_submit': opts.por_in_submit,
|
||||||
|
'por_shall_be_ciphered': not opts.por_no_ciphering,
|
||||||
|
'por_rc_cc_ds': opts.por_rc_cc_ds,
|
||||||
|
'por': opts.por_req}
|
||||||
|
if opts.format == 'expanded':
|
||||||
|
# TS 102 226 5.2.1.1: wrap each apdu in its own C-APDU TLV
|
||||||
|
apdu = [h2b(a) for a in opts.apdu]
|
||||||
|
else:
|
||||||
|
# compact: C-APDUs are concatenated as single command string
|
||||||
|
apdu = h2b("".join(opts.apdu))
|
||||||
|
|
||||||
|
smpp_handler = SmppHandler(opts.host, opts.port, opts.system_id, opts.password, ota_keyset, spi,
|
||||||
|
h2b(opts.tar), remote_format=opts.format)
|
||||||
|
resp, sw = smpp_handler.transceive_apdu(apdu, opts.src_addr, opts.dest_addr, opts.timeout)
|
||||||
|
print("%s %s" % (b2h(resp), b2h(sw)))
|
||||||
+229
-12
@@ -1,4 +1,4 @@
|
|||||||
Retrieving card-individual keys via CardKeyProvider
|
Retrieving card-individual keys via CardKeyProvider
|
||||||
===================================================
|
===================================================
|
||||||
|
|
||||||
When working with a batch of cards, or more than one card in general, it
|
When working with a batch of cards, or more than one card in general, it
|
||||||
@@ -20,9 +20,11 @@ example develop your own CardKeyProvider that queries some kind of
|
|||||||
database for the key material, or that uses a key derivation function to
|
database for the key material, or that uses a key derivation function to
|
||||||
derive card-specific key material from a global master key.
|
derive card-specific key material from a global master key.
|
||||||
|
|
||||||
The only actual CardKeyProvider implementation included in pySim is the
|
pySim already includes two CardKeyProvider implementations. One to retrieve
|
||||||
`CardKeyProviderCsv` which retrieves the key material from a
|
key material from a CSV file (`CardKeyProviderCsv`) and a second one that allows
|
||||||
[potentially encrypted] CSV file.
|
to retrieve the key material from a PostgreSQL database (`CardKeyProviderPgsql`).
|
||||||
|
Both implementations equally implement a column encryption scheme that allows
|
||||||
|
to protect sensitive columns using a *transport key*
|
||||||
|
|
||||||
|
|
||||||
The CardKeyProviderCsv
|
The CardKeyProviderCsv
|
||||||
@@ -40,11 +42,224 @@ of pySim-shell. If you do not specify a CSV file, pySim will attempt to
|
|||||||
open a CSV file from the default location at
|
open a CSV file from the default location at
|
||||||
`~/.osmocom/pysim/card_data.csv`, and use that, if it exists.
|
`~/.osmocom/pysim/card_data.csv`, and use that, if it exists.
|
||||||
|
|
||||||
|
The `CardKeyProviderCsv` is suitable to manage small amounts of key material
|
||||||
|
locally. However, if your card inventory is very large and the key material
|
||||||
|
must be made available on multiple sites, the `CardKeyProviderPgsql` is the
|
||||||
|
better option.
|
||||||
|
|
||||||
|
|
||||||
|
The CardKeyProviderPgsql
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
With the `CardKeyProviderPgsql` you can use a PostgreSQL database as storage
|
||||||
|
medium. The implementation comes with a CSV importer tool that consumes the
|
||||||
|
same CSV files you would normally use with the `CardKeyProviderCsv`, so you
|
||||||
|
can just use your existing CSV files and import them into the database.
|
||||||
|
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
^^^^^^^^^^^^
|
||||||
|
|
||||||
|
The `CardKeyProviderPgsql` uses the `Psycopg` PostgreSQL database adapter
|
||||||
|
(https://www.psycopg.org). `Psycopg` is not part of the default requirements
|
||||||
|
of pySim-shell and must be installed separately. `Psycopg` is available as
|
||||||
|
Python package under the name `psycopg2-binary`.
|
||||||
|
|
||||||
|
|
||||||
|
Setting up the database
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
From the perspective of the database, the `CardKeyProviderPgsql` has only
|
||||||
|
minimal requirements. You do not have to create any tables in advance. An empty
|
||||||
|
database and at least one user that may create, alter and insert into tables is
|
||||||
|
sufficient. However, for increased reliability and as a protection against
|
||||||
|
incorrect operation, the `CardKeyProviderPgsql` supports a hierarchical model
|
||||||
|
with three users (or roles):
|
||||||
|
|
||||||
|
* **admin**:
|
||||||
|
This should be the owner of the database. It is intended to be used for
|
||||||
|
administrative tasks like adding new tables or adding new columns to existing
|
||||||
|
tables. This user should not be used to insert new data into tables or to access
|
||||||
|
data from within pySim-shell using the `CardKeyProviderPgsql`
|
||||||
|
|
||||||
|
* **importer**:
|
||||||
|
This user is used when feeding new data into an existing table. It should only
|
||||||
|
be able to insert new rows into existing tables. It should not be used for
|
||||||
|
administrative tasks or to access data from within pySim-shell using the
|
||||||
|
`CardKeyProviderPgsql`
|
||||||
|
|
||||||
|
* **reader**:
|
||||||
|
To access data from within pySim shell using the `CardKeyProviderPgsql` the
|
||||||
|
reader user is the correct one to use. This user should have no write access
|
||||||
|
to the database or any of the tables.
|
||||||
|
|
||||||
|
|
||||||
|
Creating a config file
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
The default location for the config file is `~/.osmocom/pysim/card_data_pgsql.cfg`
|
||||||
|
The file uses `yaml` syntax and should look like the example below:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
host: "127.0.0.1"
|
||||||
|
db_name: "my_database"
|
||||||
|
table_names:
|
||||||
|
- "uicc_keys"
|
||||||
|
- "euicc_keys"
|
||||||
|
db_users:
|
||||||
|
admin:
|
||||||
|
name: "my_admin_user"
|
||||||
|
pass: "my_admin_password"
|
||||||
|
importer:
|
||||||
|
name: "my_importer_user"
|
||||||
|
pass: "my_importer_password"
|
||||||
|
reader:
|
||||||
|
name: "my_reader_user"
|
||||||
|
pass: "my_reader_password"
|
||||||
|
|
||||||
|
This file is used by pySim-shell and by the importer tool. Both expect the file
|
||||||
|
in the aforementioned location. In case you want to store the file in a
|
||||||
|
different location you may use the `--pgsql` commandline option to provide a
|
||||||
|
custom config file path.
|
||||||
|
|
||||||
|
The hostname and the database name for the PostgreSQL database is set with the
|
||||||
|
`host` and `db_name` fields. The field `db_users` sets the user names and
|
||||||
|
passwords for each of the aforementioned users (or roles). In case only a single
|
||||||
|
admin user is used, all three entries may be populated with the same user name
|
||||||
|
and password (not recommended)
|
||||||
|
|
||||||
|
The field `table_names` sets the tables that the `CardKeyProviderPgsql` shall
|
||||||
|
use to query to locate card key data. You can set up as many tables as you
|
||||||
|
want, `CardKeyProviderPgsql` will query them in order, one by one until a
|
||||||
|
matching entry is found.
|
||||||
|
|
||||||
|
NOTE: In case you do not want to disclose the admin and the importer credentials
|
||||||
|
to pySim-shell you may remove those lines. pySim-shell will only require the
|
||||||
|
`reader` entry under `db_users`.
|
||||||
|
|
||||||
|
|
||||||
|
Using the Importer
|
||||||
|
^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Before data can be imported, you must first create a database table. Tables
|
||||||
|
are created with the provided importer tool, which can be found under
|
||||||
|
`contrib/csv-to-pgsql.py`. This tool is used to create the database table and
|
||||||
|
read the data from the provided CSV file into the database.
|
||||||
|
|
||||||
|
As mentioned before, all CSV file formats that work with `CardKeyProviderCsv`
|
||||||
|
may be used. To demonstrate how the import process works, let's assume you want
|
||||||
|
to import a CSV file format that looks like the following example. Let's also
|
||||||
|
assume that you didn't get the Global Platform keys from your card vendor for
|
||||||
|
this batch of UICC cards, so your CSV file lacks the columns for those fields.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
"id","imsi","iccid","acc","pin1","puk1","pin2","puk2","ki","opc","adm1"
|
||||||
|
"card1","999700000000001","8900000000000000001","0001","1111","11111111","0101","01010101","11111111111111111111111111111111","11111111111111111111111111111111","11111111"
|
||||||
|
"card2","999700000000002","8900000000000000002","0002","2222","22222222","0202","02020202","22222222222222222222222222222222","22222222222222222222222222222222","22222222"
|
||||||
|
"card3","999700000000003","8900000000000000003","0003","3333","22222222","0303","03030303","33333333333333333333333333333333","33333333333333333333333333333333","33333333"
|
||||||
|
|
||||||
|
Since this is your first import, the database still lacks the table. To
|
||||||
|
instruct the importer to create a new table, you may use the `--create-table`
|
||||||
|
option. You also have to pick an appropriate name for the table. Any name may
|
||||||
|
be chosen as long as it contains the string `uicc_keys` or `euicc_keys`,
|
||||||
|
depending on the type of data (`UICC` or `eUICC`) you intend to store in the
|
||||||
|
table. The creation of the table is an administrative task and can only be done
|
||||||
|
with the `admin` user. The `admin` user is selected using the `--admin` switch.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_01.csv --table-name uicc_keys --create-table --admin
|
||||||
|
INFO: CSV file: ./csv-to-pgsql_example_01.csv
|
||||||
|
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1']
|
||||||
|
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
|
||||||
|
INFO: Database host: 127.0.0.1
|
||||||
|
INFO: Database name: my_database
|
||||||
|
INFO: Database user: my_admin_user
|
||||||
|
INFO: New database table created: uicc_keys
|
||||||
|
INFO: Database table: uicc_keys
|
||||||
|
INFO: Database table columns: ['ICCID', 'IMSI']
|
||||||
|
INFO: Adding missing columns: ['PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC']
|
||||||
|
INFO: Changes to table uicc_keys committed!
|
||||||
|
|
||||||
|
The importer has created a new table with the name `uicc_keys`. The table is
|
||||||
|
now ready to be filled with data.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_01.csv --table-name uicc_keys
|
||||||
|
INFO: CSV file: ./csv-to-pgsql_example_01.csv
|
||||||
|
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1']
|
||||||
|
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
|
||||||
|
INFO: Database host: 127.0.0.1
|
||||||
|
INFO: Database name: my_database
|
||||||
|
INFO: Database user: my_importer_user
|
||||||
|
INFO: Database table: uicc_keys
|
||||||
|
INFO: Database table columns: ['ICCID', 'IMSI', 'PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC']
|
||||||
|
INFO: CSV file import done, 3 rows imported
|
||||||
|
INFO: Changes to table uicc_keys committed!
|
||||||
|
|
||||||
|
A quick `SELECT * FROM uicc_keys;` at the PostgreSQL console should now display
|
||||||
|
the contents of the CSV file you have fed into the importer.
|
||||||
|
|
||||||
|
Let's now assume that with your next batch of UICC cards your vendor includes
|
||||||
|
the Global Platform keys so your CSV format changes. It may now look like this:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
"id","imsi","iccid","acc","pin1","puk1","pin2","puk2","ki","opc","adm1","scp02_dek_1","scp02_enc_1","scp02_mac_1"
|
||||||
|
"card4","999700000000004","8900000000000000004","0004","4444","44444444","0404","04040404","44444444444444444444444444444444","44444444444444444444444444444444","44444444","44444444444444444444444444444444","44444444444444444444444444444444","44444444444444444444444444444444"
|
||||||
|
"card5","999700000000005","8900000000000000005","0005","4444","55555555","0505","05050505","55555555555555555555555555555555","55555555555555555555555555555555","55555555","55555555555555555555555555555555","55555555555555555555555555555555","55555555555555555555555555555555"
|
||||||
|
"card6","999700000000006","8900000000000000006","0006","4444","66666666","0606","06060606","66666666666666666666666666666666","66666666666666666666666666666666","66666666","66666666666666666666666666666666","66666666666666666666666666666666","66666666666666666666666666666666"
|
||||||
|
|
||||||
|
When importing data from an updated CSV format the database table also has
|
||||||
|
to be updated. This is done using the `--update-columns` switch. Like when
|
||||||
|
creating new tables, this operation also requires admin privileges, so the
|
||||||
|
`--admin` switch is required again.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_02.csv --table-name uicc_keys --update-columns --admin
|
||||||
|
INFO: CSV file: ./csv-to-pgsql_example_02.csv
|
||||||
|
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1', 'SCP02_DEK_1', 'SCP02_ENC_1', 'SCP02_MAC_1']
|
||||||
|
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
|
||||||
|
INFO: Database host: 127.0.0.1
|
||||||
|
INFO: Database name: my_database
|
||||||
|
INFO: Database user: my_admin_user
|
||||||
|
INFO: Database table: uicc_keys
|
||||||
|
INFO: Database table columns: ['ICCID', 'IMSI', 'PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC']
|
||||||
|
INFO: Adding missing columns: ['SCP02_ENC_1', 'SCP02_MAC_1', 'SCP02_DEK_1']
|
||||||
|
INFO: Changes to table uicc_keys committed!
|
||||||
|
|
||||||
|
When the new table columns are added, the import may be continued like the
|
||||||
|
first one:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_02.csv --table-name uicc_keys
|
||||||
|
INFO: CSV file: ./csv-to-pgsql_example_02.csv
|
||||||
|
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1', 'SCP02_DEK_1', 'SCP02_ENC_1', 'SCP02_MAC_1']
|
||||||
|
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
|
||||||
|
INFO: Database host: 127.0.0.1
|
||||||
|
INFO: Database name: my_database
|
||||||
|
INFO: Database user: my_importer_user
|
||||||
|
INFO: Database table: uicc_keys
|
||||||
|
INFO: Database table columns: ['ICCID', 'IMSI', 'PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC', 'SCP02_ENC_1', 'SCP02_MAC_1', 'SCP02_DEK_1']
|
||||||
|
INFO: CSV file import done, 3 rows imported
|
||||||
|
INFO: Changes to table uicc_keys committed!
|
||||||
|
|
||||||
|
On the PostgreSQL console a `SELECT * FROM uicc_keys;` should now show the
|
||||||
|
imported data with the added columns. All important data should now also be
|
||||||
|
available from within pySim-shell via the `CardKeyProviderPgsql`.
|
||||||
|
|
||||||
|
|
||||||
Column-Level CSV encryption
|
Column-Level CSV encryption
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
---------------------------
|
||||||
|
|
||||||
pySim supports column-level CSV encryption. This feature will make sure
|
pySim supports column-level CSV encryption. This feature will make sure
|
||||||
that your key material is not stored in plaintext in the CSV file.
|
that your key material is not stored in plaintext in the CSV file (or
|
||||||
|
database).
|
||||||
|
|
||||||
The encryption mechanism uses AES in CBC mode. You can use any key
|
The encryption mechanism uses AES in CBC mode. You can use any key
|
||||||
length permitted by AES (128/192/256 bit).
|
length permitted by AES (128/192/256 bit).
|
||||||
@@ -72,6 +287,8 @@ by all columns of the set:
|
|||||||
* `SCP03_ISDA` is a group alias for `SCP03_ENC_ISDA`, `SCP03_MAC_ISDA`, `SCP03_DEK_ISDA`
|
* `SCP03_ISDA` is a group alias for `SCP03_ENC_ISDA`, `SCP03_MAC_ISDA`, `SCP03_DEK_ISDA`
|
||||||
* `SCP03_ISDR` is a group alias for `SCP03_ENC_ISDR`, `SCP03_MAC_ISDR`, `SCP03_DEK_ISDR`
|
* `SCP03_ISDR` is a group alias for `SCP03_ENC_ISDR`, `SCP03_MAC_ISDR`, `SCP03_DEK_ISDR`
|
||||||
|
|
||||||
|
NOTE: When using `CardKeyProviderPqsl`, the input CSV files must be encrypted
|
||||||
|
before import.
|
||||||
|
|
||||||
Field naming
|
Field naming
|
||||||
------------
|
------------
|
||||||
@@ -82,22 +299,22 @@ Field naming
|
|||||||
* For look-up of eUICC specific key material (like SCP03 keys for the
|
* For look-up of eUICC specific key material (like SCP03 keys for the
|
||||||
ISD-R, ECASD), pySim uses the `EID` field as lookup key.
|
ISD-R, ECASD), pySim uses the `EID` field as lookup key.
|
||||||
|
|
||||||
As soon as the CardKeyProviderCsv finds a line (row) in your CSV where
|
As soon as the CardKeyProvider finds a line (row) in your CSV file
|
||||||
the ICCID or EID match, it looks for the column containing the requested
|
(or database) where the ICCID or EID match, it looks for the column containing
|
||||||
data.
|
the requested data.
|
||||||
|
|
||||||
|
|
||||||
ADM PIN
|
ADM PIN
|
||||||
~~~~~~~
|
^^^^^^^
|
||||||
|
|
||||||
The `verify_adm` command will attempt to look up the `ADM1` column
|
The `verify_adm` command will attempt to look up the `ADM1` column
|
||||||
indexed by the ICCID of the SIM/UICC.
|
indexed by the ICCID of the SIM/UICC.
|
||||||
|
|
||||||
|
|
||||||
SCP02 / SCP03
|
SCP02 / SCP03
|
||||||
~~~~~~~~~~~~~
|
^^^^^^^^^^^^^
|
||||||
|
|
||||||
SCP02 and SCP03 each use key triplets consisting if ENC, MAC and DEK
|
SCP02 and SCP03 each use key triplets consisting of ENC, MAC and DEK
|
||||||
keys. For more details, see the applicable GlobalPlatform
|
keys. For more details, see the applicable GlobalPlatform
|
||||||
specifications.
|
specifications.
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -13,6 +13,7 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, os.path.abspath('..'))
|
sys.path.insert(0, os.path.abspath('..'))
|
||||||
|
sys.path.insert(0, os.path.abspath('.')) # for local extensions (pysim_fs_sphinx, ...)
|
||||||
|
|
||||||
|
|
||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
@@ -39,7 +40,8 @@ extensions = [
|
|||||||
"sphinx.ext.autodoc",
|
"sphinx.ext.autodoc",
|
||||||
"sphinxarg.ext",
|
"sphinxarg.ext",
|
||||||
"sphinx.ext.autosectionlabel",
|
"sphinx.ext.autosectionlabel",
|
||||||
"sphinx.ext.napoleon"
|
"sphinx.ext.napoleon",
|
||||||
|
"pysim_fs_sphinx",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
@@ -64,3 +66,25 @@ html_theme = 'alabaster'
|
|||||||
html_static_path = ['_static']
|
html_static_path = ['_static']
|
||||||
|
|
||||||
autoclass_content = 'both'
|
autoclass_content = 'both'
|
||||||
|
|
||||||
|
# Mock optional server-side deps of es2p and http_json_api/es9p,
|
||||||
|
# so that autodoc can import and document those modules.
|
||||||
|
autodoc_mock_imports = ['klein', 'twisted']
|
||||||
|
|
||||||
|
# Workaround for duplicate label warnings:
|
||||||
|
# https://github.com/sphinx-doc/sphinx-argparse/issues/14
|
||||||
|
#
|
||||||
|
# sphinxarg.ext generates generic sub-headings ("Named arguments",
|
||||||
|
# "Positional arguments", "Sub-commands", "General options", ...) for every
|
||||||
|
# argparse command/tool. These repeat across many files and trigger tons
|
||||||
|
# of autosectionlabel duplicate-label warnings - suppress them.
|
||||||
|
autosectionlabel_maxdepth = 3
|
||||||
|
suppress_warnings = [
|
||||||
|
'autosectionlabel.filesystem',
|
||||||
|
'autosectionlabel.saip-tool',
|
||||||
|
'autosectionlabel.shell',
|
||||||
|
'autosectionlabel.smpp2sim',
|
||||||
|
'autosectionlabel.smpp-ota-tool',
|
||||||
|
'autosectionlabel.suci-keytool',
|
||||||
|
'autosectionlabel.trace',
|
||||||
|
]
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pySim consists of several parts:
|
|||||||
:caption: Contents:
|
:caption: Contents:
|
||||||
|
|
||||||
shell
|
shell
|
||||||
|
filesystem
|
||||||
trace
|
trace
|
||||||
legacy
|
legacy
|
||||||
smpp2sim
|
smpp2sim
|
||||||
@@ -48,6 +49,7 @@ pySim consists of several parts:
|
|||||||
sim-rest
|
sim-rest
|
||||||
suci-keytool
|
suci-keytool
|
||||||
saip-tool
|
saip-tool
|
||||||
|
smpp-ota-tool
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
|
|||||||
+1
-1
@@ -205,7 +205,7 @@ Specifically, pySim-read will dump the following:
|
|||||||
|
|
||||||
* DF.GSM
|
* DF.GSM
|
||||||
|
|
||||||
* EF,IMSI
|
* EF.IMSI
|
||||||
* EF.GID1
|
* EF.GID1
|
||||||
* EF.GID2
|
* EF.GID2
|
||||||
* EF.SMSP
|
* EF.SMSP
|
||||||
|
|||||||
@@ -0,0 +1,836 @@
|
|||||||
|
Guide: Managing GP Keys
|
||||||
|
=======================
|
||||||
|
|
||||||
|
Most of today's smartcards follow the GlobalPlatform Card Specification and the included Security Domain model.
|
||||||
|
UICCs and eUCCCs are no exception here.
|
||||||
|
|
||||||
|
The Security Domain acts as an on-card representative of a card authority or administrator. It is used to perform tasks
|
||||||
|
like the installation of applications or the provisioning and rotation of secure channel keys. It also acts as a secure
|
||||||
|
key storage and offers all kinds of cryptographic services to applications that are installed under a specific
|
||||||
|
Security Domain (see also GlobalPlatform Card Specification, section 7).
|
||||||
|
|
||||||
|
In this tutorial, we will show how to work with the key material (keysets) stored inside a Security Domain and how to
|
||||||
|
rotate (replace) existing keys. We will also show how to provision new keys.
|
||||||
|
|
||||||
|
.. warning:: Making changes to keysets requires extreme caution as misconfigured keysets may lock you out permanently.
|
||||||
|
It's also strongly recommended to maintain at least one backup keyset that you can use as fallback in case
|
||||||
|
the primary keyset becomes unusable for some reason.
|
||||||
|
|
||||||
|
|
||||||
|
Selecting a Security Domain
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
A typical smartcard, such as an UICC will have one primary Security Domain, called the Issuer Security Domain (ISD).
|
||||||
|
When working with those cards, the ISD will show up in the UICC filesystem tree as `ADF.ISD` and can be selected like
|
||||||
|
any other file.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (00:MF)> select ADF.ISD
|
||||||
|
{
|
||||||
|
"application_id": "a000000003000000",
|
||||||
|
"proprietary_data": {
|
||||||
|
"maximum_length_of_data_field_in_command_message": 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
When working with eUICCs, multiple Security Domains are involved. The model is fundamentally different from the classic
|
||||||
|
model with one primary Security Domain (ISD). In the case of eUICCs, an ISD-R (Issuer Security Domain - Root) and an
|
||||||
|
ISD-P (Issuer Security Domain - Profile) exist (see also: GSMA SGP.02, section 2.2.1).
|
||||||
|
|
||||||
|
The ISD-P is established by the ISD-R during the profile installation and serves as a secure container for an eSIM
|
||||||
|
profile. Within the ISD-P the eSIM profile establishes a dedicated Security Domain called `MNO-SD` (see also GSMA
|
||||||
|
SGP.02, section 2.2.4). This `MNO-SD` is comparable to the Issuer Security Domain (ISD) we find on UICCs. The AID of
|
||||||
|
`MNO-SD` is either the default AID for the Issuer Security Domain (see also GlobalPlatform, section H.1.3) or a
|
||||||
|
different value specified by the provider of the eSIM profile.
|
||||||
|
|
||||||
|
Since the AID of the `MNO-SD` is not a fixed value, it is not known by `pySim-shell`. This means there will be no
|
||||||
|
`ADF.ISD` file shown in the file system, but we can simply select the `ADF.ISD-R` first and then select the `MNO-SD`
|
||||||
|
using a raw APDU. In the following example we assume that the default AID (``a000000151000000``) is used The APDU
|
||||||
|
would look like this: ``00a4040408`` + ``a000000151000000`` + ``00``
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (00:MF)> select ADF.ISD-R
|
||||||
|
{
|
||||||
|
"application_id": "a0000005591010ffffffff8900000100",
|
||||||
|
"proprietary_data": {
|
||||||
|
"maximum_length_of_data_field_in_command_message": 255
|
||||||
|
},
|
||||||
|
"isdr_proprietary_application_template": {
|
||||||
|
"supported_version_number": "020300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pySIM-shell (00:MF/ADF.ISD-R)> apdu 00a4040408a00000015100000000
|
||||||
|
SW: 9000, RESP: 6f108408a000000151000000a5049f6501ff
|
||||||
|
|
||||||
|
After that, the prompt will still show the `ADF.ISD-R`, but we are actually in `ADF.ISD` and the standard GlobalPlatform
|
||||||
|
operations like `establish_scpXX`, `get_data`, and `put_key` should work. By doing this, we simply have tricked
|
||||||
|
`pySim-shell` into making the GlobalPlatform related commands available for some other Security Domain we are not
|
||||||
|
interested in. With the raw APDU we then have swapped out the Security Domain under the hood. The same workaround can
|
||||||
|
be applied to any Security Domain, provided that the AID is known to the user.
|
||||||
|
|
||||||
|
|
||||||
|
Establishing a secure channel
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Before we can make changes to the keysets in the currently selected Security Domain we must first establish a secure
|
||||||
|
channel with that Security Domain. In the following examples we will use `SCP02` (see also GlobalPlatform Card
|
||||||
|
Specification, section E.1.1) and `SCP03` (see also GlobalPlatform Card Specification – Amendment D) to establish the
|
||||||
|
secure channel. `SCP02` is slightly older than `SCP03`. The main difference between the two is that `SCP02` uses 3DES
|
||||||
|
while `SCP03` is based on AES.
|
||||||
|
|
||||||
|
.. warning:: Secure channel protocols like `SCP02` and `SCP03` may manage an error counter to count failed login
|
||||||
|
attempts. This means attempting to establish a secure channel with a wrong keyset multiple times may lock
|
||||||
|
you out permanently. Double check the applied keyset before attempting to establish a secure channel.
|
||||||
|
|
||||||
|
.. warning:: The key values used in the following examples are random key values used for illustration purposes only.
|
||||||
|
Each UICC or eSIM profile is shipped with individual keys, which means that the keys used below will not
|
||||||
|
work with your UICC or eSIM profile. You must replace the key values with the values you have received
|
||||||
|
from your UICC vendor or eSIM profile provider.
|
||||||
|
|
||||||
|
|
||||||
|
Example: `SCP02`
|
||||||
|
----------------
|
||||||
|
|
||||||
|
In the following example, we assume that we want to establish a secure channel with the ISD of a `sysmoUSIM-SJA5` UICC.
|
||||||
|
Along with the card we have received the following keyset:
|
||||||
|
|
||||||
|
+---------+----------------------------------+
|
||||||
|
| Keyname | Keyvalue |
|
||||||
|
+=========+==================================+
|
||||||
|
| ENC/KIC | F09C43EE1A0391665CC9F05AF4E0BD10 |
|
||||||
|
+---------+----------------------------------+
|
||||||
|
| MAC/KID | 01981F4A20999F62AF99988007BAF6CA |
|
||||||
|
+---------+----------------------------------+
|
||||||
|
| DEK/KIK | 8F8AEE5CDCC5D361368BC45673D99195 |
|
||||||
|
+---------+----------------------------------+
|
||||||
|
|
||||||
|
This keyset is tied to the key version number KVN 122 and is configured as a DES keyset. We can use this keyset to
|
||||||
|
establish a secure channel using the SCP02 Secure Channel Protocol.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (00:MF/ADF.ISD)> establish_scp02 --key-enc F09C43EE1A0391665CC9F05AF4E0BD10 --key-mac 01981F4A20999F62AF99988007BAF6CA --key-dek 8F8AEE5CDCC5D361368BC45673D99195 --key-ver 112 --security-level 3
|
||||||
|
Successfully established a SCP02[03] secure channel
|
||||||
|
|
||||||
|
|
||||||
|
Example: `SCP03`
|
||||||
|
----------------
|
||||||
|
|
||||||
|
The establishment of a secure channel via SCP03 works just the same. In the following example we will establish a
|
||||||
|
secure channel to the `MNO-SD` of an eSIM profile. The SCP03 keyset we use is tied to KVN 48 and looks like this:
|
||||||
|
|
||||||
|
+---------+------------------------------------------------------------------+
|
||||||
|
| Keyname | Keyvalue |
|
||||||
|
+=========+==================================================================+
|
||||||
|
| ENC/KIC | 63af517c29ad6ac6fcadfe6ac8a3c8a041d8141c7eb845ef1cba6112a325e430 |
|
||||||
|
+---------+------------------------------------------------------------------+
|
||||||
|
| MAC/KID | 54b9ad6713ae922f54014ed762132e7b59bdcd2a2a6beba98fb9afe6b4df27e1 |
|
||||||
|
+---------+------------------------------------------------------------------+
|
||||||
|
| DEK/KIK | cbb933ba2389da93c86c112739cd96389139f16c6f80f7d16bf3593e407ca893 |
|
||||||
|
+---------+------------------------------------------------------------------+
|
||||||
|
|
||||||
|
We assume that the `MNO-SD` is already selected (see above). We may now establish the SCP03 secure channel:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (00:MF/ADF.ISD-R)> establish_scp03 --key-enc 63af517c29ad6ac6fcadfe6ac8a3c8a041d8141c7eb845ef1cba6112a325e430 --key-mac 54b9ad6713ae922f54014ed762132e7b59bdcd2a2a6beba98fb9afe6b4df27e1 --key-dek cbb933ba2389da93c86c112739cd96389139f16c6f80f7d16bf3593e407ca893 --key-ver 48 --security-level 3
|
||||||
|
Successfully established a SCP03[03] secure channel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Understanding Keysets
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Before making any changes to keysets, it is recommended to check the status of the currently installed keysets. To do
|
||||||
|
so, we use the `get_data` command to retrieve the `key_information`. This command does not require the establishment of
|
||||||
|
a secure channel. We also cannot read back the key values themselves, but we get a summary of the installed keys
|
||||||
|
together with their KVN numbers, IDs, algorithm and key length values.
|
||||||
|
|
||||||
|
Example: `key_information` from a `sysmoISIM-SJA5`:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
|
||||||
|
{
|
||||||
|
"key_information": [
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 112,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 112,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 112,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 1,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 1,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 1,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 2,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 2,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 2,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 47,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 47,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 47,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Example: `key_information` from a `sysmoEUICC1-C2T`:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP03[03]:00:MF/ADF.ISD-R)> get_data key_information
|
||||||
|
{
|
||||||
|
"key_information": [
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 50,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 32
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 50,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 32
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 50,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 32
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 64,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 64,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "tls_psk",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
The output from those two examples above may seem lengthy, but in order to move on and to provision own keys
|
||||||
|
successfully, it is important to understand each aspect of it.
|
||||||
|
|
||||||
|
Key Version Number (KVN)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Each key is associated with a Key Version Number (KVN). Multiple keys that share the same KVN belong to the same
|
||||||
|
keyset. In the first example above we can see that four keysets with KVN numbers 112, 1, 2 and 47 are provisioned.
|
||||||
|
In the second example we see two keysets. One with KVN 50 and one with KVN 64.
|
||||||
|
|
||||||
|
The term "Key Version Number" is misleading as this number is not really a version number. It's actually a unique
|
||||||
|
identifier for a specific keyset that also defines with which Secure Channel Protocol a key can be used. This means
|
||||||
|
that the KVN is not just an arbitrary number. The following (incomplete) table gives a hint which KVN numbers may be
|
||||||
|
used with which Secure Channel Protocol.
|
||||||
|
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| KVN range | Secure Channel Protocol |
|
||||||
|
+===========+=======================================================+
|
||||||
|
| 1-15 | reserved for `SCP80` (OTA SMS) |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 17 | reserved for DAP specified in ETSI TS 102 226 |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 32-47 | reserved for `SCP02` |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 48-63 | reserved for `SCP03` |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 64-79 | reserved for `SCP81` (GSMA SGP.02, section 2.2.5.1) |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 112 | Token key (RSA public or DES, also used with `SCP02`) |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 113 | Receipt key (DES) |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 115 | DAP verification key (RS public or DES) |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 116 | reserved for CASD |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 117 | 16-byte DES key for Ciphered Load File Data Block |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
| 255 | reserved for ISD with SCP02 without SCP80 support |
|
||||||
|
+-----------+-------------------------------------------------------+
|
||||||
|
|
||||||
|
With that we can now understand that in the first example, the first and the last keyset is intended to be used with
|
||||||
|
`SCP02` and that the second and the third keyset is intended to be used with `SCP80` (OTA SMS). In the second example we
|
||||||
|
can see that the first keyset is intended to be used with `SCP03`, wheres the second should be usable with `SCP81`.
|
||||||
|
|
||||||
|
|
||||||
|
Key Identifier
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Each keyset consists of a number of keys, where each key has a different Key Identifier. The Key Identifier is usually
|
||||||
|
an incrementing number that starts counting at 1. The Key Identifier is used to distinguish the keys within the keyset.
|
||||||
|
The exact number of keys and their attributes depends on the secure channel protocol for which the keyset is intended
|
||||||
|
for. Each secure channel protocol may have its specific requirements on how many keys of which which type, length or
|
||||||
|
Key Identifier have to be present.
|
||||||
|
|
||||||
|
However, almost all of the classic secure channel protocols (including `SCP02`, `SCP03` and `SCP81`) make use of the
|
||||||
|
following three-key scheme:
|
||||||
|
|
||||||
|
+----------------+---------+---------------------------------------+
|
||||||
|
| Key Identifier | Keyname | Purpose |
|
||||||
|
+================+=========+=======================================+
|
||||||
|
| 1 | ENC/KIC | encryption/decryption |
|
||||||
|
+----------------+---------+---------------------------------------+
|
||||||
|
| 2 | MAC/KID | cryptographic checksumming/signing |
|
||||||
|
+----------------+---------+---------------------------------------+
|
||||||
|
| 3 | DEK/KIK | encryption/decryption of key material |
|
||||||
|
+----------------+---------+---------------------------------------+
|
||||||
|
|
||||||
|
In this case, all three keys share the same length and are used with the same algorithm. The key length is often used
|
||||||
|
to implicitly select sub-types of an algorithm. (e.g. a 16 byte key of type `aes` is associated with `AES128`, where a 32
|
||||||
|
byte key would be associated with `AES256`).
|
||||||
|
|
||||||
|
The second example shows that different schemes are possible. The `SCP80` keyset from the second example uses a scheme
|
||||||
|
that works with two keys:
|
||||||
|
|
||||||
|
+----------------+---------+---------------------------------------+
|
||||||
|
| Key Identifier | Keyname | Purpose |
|
||||||
|
+================+=========+=======================================+
|
||||||
|
| 1 | TLS-PSK | pre-shared key used for TLS |
|
||||||
|
+----------------+---------+---------------------------------------+
|
||||||
|
| 2 | DEK/KIK | encryption/decryption of key material |
|
||||||
|
+----------------+---------+---------------------------------------+
|
||||||
|
|
||||||
|
It should also be noted that the order in which keysets and keys appear is an implementation detail of the UICC/eUICC
|
||||||
|
O/S. The order has no influence on how a keyset is interpreted. Only the Key Version Number (KVN) and the Key Identifier
|
||||||
|
matter.
|
||||||
|
|
||||||
|
|
||||||
|
Rotating a keyset
|
||||||
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Rotating keys is one of the most basic tasks one might want to perform on an UICC/eUICC before using it productively. In
|
||||||
|
the following example we will illustrate how key rotation can be done. When rotating keys, only the key itself may
|
||||||
|
change. For example it is not possible to change the key length or the algorithm used (see also GlobalPlatform Card
|
||||||
|
Specification, section 11.8.2.3.3). Any key of the current Security Domain can be rotated, this also includes the key
|
||||||
|
that was used to establish the secure channel.
|
||||||
|
|
||||||
|
In the following example we assume that the Security Domain is selected and a secure channel is already established. We
|
||||||
|
intend to rotate the keyset with KVN 112. Since this keyset uses triple DES keys with a key length of 16, we must
|
||||||
|
replace it with a keyset with keys of the same nature.
|
||||||
|
|
||||||
|
The new keyset shall look like this:
|
||||||
|
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| Key Identifier | Keyname | Keyvalue |
|
||||||
|
+================+=========+==================================+
|
||||||
|
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5 |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
|
||||||
|
When passing the keys to the `put_key` commandline, we set the Key Identifier of the first key using the `--key-id`
|
||||||
|
parameter. This Key Identifier will be valid for the first key (KIC) we pass. For all consecutive keys, the Key
|
||||||
|
Identifier will be incremented automatically (see also GlobalPlatform Card Specification, section 11.8.2.2). To Ensure
|
||||||
|
that the new KIC, KID and KIK keys get the correct Key Identifiers, it is crucial to maintain order when passing the
|
||||||
|
keys in the `--key-data` arguments. It is also important that each `--key-data` argument is preceded by a `--key-type`
|
||||||
|
argument that sets the algorithm correctly (`des` in this case).
|
||||||
|
|
||||||
|
Finally we have to target the keyset we want to rotate by its KVN. The `--old-key-version-nr` argument is set to 112
|
||||||
|
as this identifies the keyset we want to rotate. The `--key-version-nr` is also set to 112 as we do not want
|
||||||
|
KVN to be changed in this example. Changing the KVN while rotating a keyset is possible. In case the KVN has to change
|
||||||
|
for some reason, the new KVN must be selected carefully to keep the key usable with the associated Secure Channel
|
||||||
|
Protocol.
|
||||||
|
|
||||||
|
The commandline that matches the keyset we had laid out above looks like this:
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type des --key-data 542C37A6043679F2F9F71116418B1CD5 --key-type des --key-data 34F11BAC8E5390B57F4E601372339E3C --key-type des --key-data 5524F4BECFE96FB63FC29D6BAAC6058B --old-key-version-nr 112 --key-version-nr 112
|
||||||
|
|
||||||
|
After executing this put_key commandline, the keyset identified by KVN 122 is equipped with new keys. We can use
|
||||||
|
`get_data key_information` to inspect the currently installed keysets. The output should appear unchanged as
|
||||||
|
we only swapped out the keys. All other parameters, identifiers etc. should remain constant.
|
||||||
|
|
||||||
|
.. warning:: It is technically possible to rotate a keyset in a `non atomic` way using one `put_key` commandline for
|
||||||
|
each key. However, in case the targeted keyset is the one used to establish the current secure channel,
|
||||||
|
this method should not be used since, depending on the UICC/eUICC model, half-written key material may
|
||||||
|
interrupt the current secure channel.
|
||||||
|
|
||||||
|
|
||||||
|
Removing a keyset
|
||||||
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
In some cases it is necessary to remove a keyset entirely. This can be done with the `delete_key` command. Here it is
|
||||||
|
important to understand that `delete_key` only removes one specific key from a specific keyset. This means that you
|
||||||
|
need to run a separate `delete_key` command for each key inside a keyset.
|
||||||
|
|
||||||
|
In the following example we assume that the Security Domain is selected and a secure channel is already established. We
|
||||||
|
intend to remove the keyset with KVN 112. This keyset consists of three keys.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> delete_key --key-ver 112 --key-id 1
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> delete_key --key-ver 112 --key-id 2
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> delete_key --key-ver 112 --key-id 3
|
||||||
|
|
||||||
|
To verify that the keyset has been deleted properly, we can use the `get_data key_information` command to inspect the
|
||||||
|
current status of the installed keysets. We should see that the key with KVN 112 is no longer present.
|
||||||
|
|
||||||
|
|
||||||
|
Adding a keyset
|
||||||
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
In the following we will discuss how to add an entirely new keyset. The procedure is almost identical with the key
|
||||||
|
rotation procedure we have already discussed and it is assumed that all details about the key rotation are understood.
|
||||||
|
In this section we will go into more detail and illustrate how to provision new 3DES, `AES128` and `AES256` keysets.
|
||||||
|
|
||||||
|
It is important to keep in mind that storage space on smartcard is a precious resource. In many cases the amount of
|
||||||
|
keysets that a Security Domain can store is limited. In some situations you may be forced to sacrifice one of your
|
||||||
|
existing keysets in favor of a new keyset.
|
||||||
|
|
||||||
|
The main difference between key rotation and the adding of new keys is that we do not simply replace an existing key.
|
||||||
|
Instead an entirely new key is programmed into the Security Domain. Therefore the `put_key` commandline will have no
|
||||||
|
`--old-key-version-nr` parameter. From the commandline perspective, this is already the only visible difference from a
|
||||||
|
commandline that simply rotates a keyset. Since we are writing an entirely new keyset, we are free to chose the
|
||||||
|
algorithm and the key length within the parameter range permitted by the targeted secure channel protocol. Otherwise
|
||||||
|
the same rules apply.
|
||||||
|
|
||||||
|
For reference, it should be mentioned that it is also possible to add or rotate keyset using multiple `put_key`
|
||||||
|
commandlines. In this case one `put_key` commandline for each key is used. Each commandline will specify `--key-id` and
|
||||||
|
`--key-version-nr` and one `--key-type` and `--key-data` tuple. However, when rotating or adding a keyset step-by-step,
|
||||||
|
the whole process happens in a `non-atomic` way, which is less reliable. Therefore we will favor the `atomic method`
|
||||||
|
|
||||||
|
In the following examples we assume that the Security Domain is selected and a secure channel is already established.
|
||||||
|
|
||||||
|
|
||||||
|
Example: `3DES` key for `SCP02`
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
Let's assume we want to provision a new 3DES keyset that we can use for SCP02. The keyset shall look like this:
|
||||||
|
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| Key Identifier | Keyname | Keyvalue |
|
||||||
|
+================+=========+==================================+
|
||||||
|
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5 |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
|
||||||
|
The keyset shall be a associated with the KVN 46. We have made sure before that KVN 46 is still unused and that this
|
||||||
|
KVN number is actually suitable for SCP02 keys. As we are using 3DES, it is obvious that we have to pass 3 keys with 16
|
||||||
|
byte length.
|
||||||
|
|
||||||
|
To program the key, we may use the following commandline. As we can see, this commandline is almost the exact same as
|
||||||
|
the one from the key rotation example where we were rotating a 3DES key. The only difference is that we didn't specify
|
||||||
|
an old KVN number and that we have chosen a different KVN.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type des --key-data 542C37A6043679F2F9F71116418B1CD5 --key-type des --key-data 34F11BAC8E5390B57F4E601372339E3C --key-type des --key-data 5524F4BECFE96FB63FC29D6BAAC6058B --key-version-nr 46
|
||||||
|
|
||||||
|
In case of success, the keyset should appear in the `key_information` among the other keysets that are already present.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
|
||||||
|
{
|
||||||
|
"key_information": [
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 46,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 46,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 46,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "des",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Example: `AES128` key for `SCP80`
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
In this example we intend to provision a new `AES128` keyset that we can use with SCP80 (OTA SMS). The keyset shall look
|
||||||
|
like this:
|
||||||
|
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| Key Identifier | Keyname | Keyvalue |
|
||||||
|
+================+=========+==================================+
|
||||||
|
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5 |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
|
||||||
|
In addition to that, we want to associate this key with KVN 3. We have inspected the currently installed keysets before
|
||||||
|
and made sure that KVN 3 is still unused. We are also aware that for SCP80 we may only use KVN values from 1 to 15.
|
||||||
|
|
||||||
|
For `AES128`, we specify the algorithm using the `--key-type aes` parameter. The selection between `AES128` and `AES256` is
|
||||||
|
done implicitly using the key length. Since we want to use `AES128` in this case, all three keys have a length of 16 byte.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type aes --key-data 542C37A6043679F2F9F71116418B1CD5 --key-type aes --key-data 34F11BAC8E5390B57F4E601372339E3C --key-type aes --key-data 5524F4BECFE96FB63FC29D6BAAC6058B --key-version-nr 3
|
||||||
|
|
||||||
|
In case of success, the keyset should appear in the `key_information` among the other keysets that are already present.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
|
||||||
|
{
|
||||||
|
"key_information": [
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 3,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 3,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 3,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Example: `AES256` key for `SCP03`
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
Let's assume we want to provision a new `AES256` keyset that we can use for SCP03. The keyset shall look like this:
|
||||||
|
|
||||||
|
+----------------+---------+------------------------------------------------------------------+
|
||||||
|
| Key Identifier | Keyname | Keyvalue |
|
||||||
|
+================+=========+==================================================================+
|
||||||
|
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5542C37A6043679F2F9F71116418B1CD5 |
|
||||||
|
+----------------+---------+------------------------------------------------------------------+
|
||||||
|
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C34F11BAC8E5390B57F4E601372339E3C |
|
||||||
|
+----------------+---------+------------------------------------------------------------------+
|
||||||
|
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B5524F4BECFE96FB63FC29D6BAAC6058B |
|
||||||
|
+----------------+---------+------------------------------------------------------------------+
|
||||||
|
|
||||||
|
In addition to that, we assume that we want to associate this key with KVN 51. This KVN number falls in the range of
|
||||||
|
48 - 63 and is therefore suitable for a key that shall be usable with SCP03. We also made sure before that KVN 51 is
|
||||||
|
still unused.
|
||||||
|
|
||||||
|
With that we can go ahead and make up the following commandline:
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type aes --key-data 542C37A6043679F2F9F71116418B1CD5542C37A6043679F2F9F71116418B1CD5 --key-type aes --key-data 34F11BAC8E5390B57F4E601372339E3C34F11BAC8E5390B57F4E601372339E3C --key-type aes --key-data 5524F4BECFE96FB63FC29D6BAAC6058B5524F4BECFE96FB63FC29D6BAAC6058B --key-version-nr 51
|
||||||
|
|
||||||
|
In case of success, we should see the keyset in the `key_information`
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
|
||||||
|
{
|
||||||
|
"key_information": [
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 51,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 32
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 51,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 32
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 3,
|
||||||
|
"key_version_number": 51,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 32
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Example: `AES128` key for `SCP81`
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
In this example we will show how to provision a new `AES128` keyset for `SCP81`. We will provision this keyset under
|
||||||
|
KVN 64. The keyset we intend to apply shall look like this:
|
||||||
|
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| Key Identifier | Keyname | Keyvalue |
|
||||||
|
+================+=========+==================================+
|
||||||
|
| 1 | TLS-PSK | 000102030405060708090a0b0c0d0e0f |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
| 2 | DEK/KIK | 000102030405060708090a0b0c0d0e0f |
|
||||||
|
+----------------+---------+----------------------------------+
|
||||||
|
|
||||||
|
With that we can put together the following command line:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
put_key --key-id 1 --key-type tls_psk --key-data 000102030405060708090a0b0c0d0e0f --key-type aes --key-data 000102030405060708090a0b0c0d0e0f --key-version-nr 64
|
||||||
|
|
||||||
|
In case of success, the keyset should appear in the `key_information` as follows:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pySIM-shell (SCP03[03]:00:MF/ADF.ISD-R)> get_data key_information
|
||||||
|
{
|
||||||
|
"key_information": [
|
||||||
|
...,
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 2,
|
||||||
|
"key_version_number": 64,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "aes",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_information_data": {
|
||||||
|
"key_identifier": 1,
|
||||||
|
"key_version_number": 64,
|
||||||
|
"key_types": [
|
||||||
|
{
|
||||||
|
"type": "tls_psk",
|
||||||
|
"length": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
Sphinx extension: auto-generate docs/filesystem.rst from the pySim EF class hierarchy.
|
||||||
|
|
||||||
|
Hooked into Sphinx's ``builder-inited`` event so the file is always regenerated
|
||||||
|
from the live Python classes before Sphinx reads any source files.
|
||||||
|
|
||||||
|
The table of root objects to document is in SECTIONS near the top of this file.
|
||||||
|
EXCLUDED lists CardProfile/CardApplication subclasses intentionally omitted from
|
||||||
|
SECTIONS, with reasons. Both tables are read by tests/unittests/test_fs_coverage.py
|
||||||
|
to ensure every class with EF/DF content is accounted for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
# Ensure pySim is importable when this module is loaded as a Sphinx extension
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from pySim.filesystem import (CardApplication, CardDF, CardMF, CardEF, # noqa: E402
|
||||||
|
TransparentEF, TransRecEF, LinFixedEF, CyclicEF, BerTlvEF)
|
||||||
|
from pySim.profile import CardProfile # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# Generic EF base classes whose docstrings describe the *type* of file
|
||||||
|
# (Transparent, LinFixed, ...) rather than a specific file's content.
|
||||||
|
# Suppress those boilerplate texts in the per-EF entries; they are only
|
||||||
|
# useful once, at the top of the document or in a dedicated glossary.
|
||||||
|
_EF_BASE_TYPES = frozenset([TransparentEF,
|
||||||
|
TransRecEF,
|
||||||
|
LinFixedEF,
|
||||||
|
CyclicEF,
|
||||||
|
BerTlvEF])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sections: (heading, module, class-name)
|
||||||
|
# The class must be either a CardProfile (uses .files_in_mf) or a CardDF
|
||||||
|
# subclass (uses .children).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SECTIONS = [
|
||||||
|
('MF / TS 102 221 (UICC)',
|
||||||
|
'pySim.ts_102_221', 'CardProfileUICC'),
|
||||||
|
('ADF.USIM / TS 31.102',
|
||||||
|
'pySim.ts_31_102', 'ADF_USIM'),
|
||||||
|
('ADF.ISIM / TS 31.103',
|
||||||
|
'pySim.ts_31_103', 'ADF_ISIM'),
|
||||||
|
('ADF.HPSIM / TS 31.104',
|
||||||
|
'pySim.ts_31_104', 'ADF_HPSIM'),
|
||||||
|
('DF.GSM + DF.TELECOM / TS 51.011 (SIM)',
|
||||||
|
'pySim.ts_51_011', 'CardProfileSIM'),
|
||||||
|
('CDMA / IS-820 (RUIM)',
|
||||||
|
'pySim.cdma_ruim', 'CardProfileRUIM'),
|
||||||
|
('DF.EIRENE / GSM-R',
|
||||||
|
'pySim.gsm_r', 'DF_EIRENE'),
|
||||||
|
('DF.SYSTEM / sysmocom SJA2+SJA5',
|
||||||
|
'pySim.sysmocom_sja2', 'DF_SYSTEM'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Excluded: {(module, class-name)}
|
||||||
|
# CardProfile and CardApplication subclasses that have EF/DF children but are
|
||||||
|
# intentionally absent from SECTIONS. Keeping this list explicit lets
|
||||||
|
# test_fs_coverage.py detect newly added classes that the developer forgot to
|
||||||
|
# add to either table.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
EXCLUDED = {
|
||||||
|
# eUICC profiles inherit files_in_mf verbatim from CardProfileUICC; the
|
||||||
|
# eUICC-specific content lives in ISD-R / ISD-P applications, not in MF.
|
||||||
|
('pySim.euicc', 'CardProfileEuiccSGP02'),
|
||||||
|
('pySim.euicc', 'CardProfileEuiccSGP22'),
|
||||||
|
('pySim.euicc', 'CardProfileEuiccSGP32'),
|
||||||
|
# CardApplication* classes are thin wrappers that embed an ADF_* instance.
|
||||||
|
# The ADF contents are already documented via the corresponding ADF_* entry
|
||||||
|
# in SECTIONS above.
|
||||||
|
('pySim.ts_31_102', 'CardApplicationUSIM'),
|
||||||
|
('pySim.ts_31_102', 'CardApplicationUSIMnonIMSI'),
|
||||||
|
('pySim.ts_31_103', 'CardApplicationISIM'),
|
||||||
|
('pySim.ts_31_104', 'CardApplicationHPSIM'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# RST underline characters ordered by nesting depth
|
||||||
|
_HEADING_CHARS = ['=', '=', '-', '~', '^', '"']
|
||||||
|
# Level 0 uses '=' with overline (page title).
|
||||||
|
# Level 1 uses '=' without overline (major sections).
|
||||||
|
# Levels 2+ use the remaining characters for DFs.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RST formatting helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _heading(title: str, level: int) -> str:
|
||||||
|
"""Return an RST heading string. Level 0 gets an overline."""
|
||||||
|
char = _HEADING_CHARS[level]
|
||||||
|
rule = char * len(title)
|
||||||
|
if level == 0:
|
||||||
|
return f'{rule}\n{title}\n{rule}\n\n'
|
||||||
|
return f'{title}\n{rule}\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(obj):
|
||||||
|
"""Fallback serialiser: bytes -> hex, anything else -> repr."""
|
||||||
|
if isinstance(obj, (bytes, bytearray)):
|
||||||
|
return obj.hex()
|
||||||
|
return repr(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _examples_block(cls) -> str:
|
||||||
|
"""Return RST code-block examples (one per vector), or '' if none exist.
|
||||||
|
|
||||||
|
Each example is rendered as a ``json5`` code-block with the hex-encoded
|
||||||
|
binary as a ``// comment`` on the first line, followed by the decoded JSON.
|
||||||
|
``json5`` is used instead of ``json`` so that Pygments does not flag the
|
||||||
|
``//`` comment as a syntax error.
|
||||||
|
"""
|
||||||
|
vectors = []
|
||||||
|
for attr in ('_test_de_encode', '_test_decode'):
|
||||||
|
v = getattr(cls, attr, None)
|
||||||
|
if v:
|
||||||
|
vectors.extend(v)
|
||||||
|
if not vectors:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
lines = ['**Examples**\n\n']
|
||||||
|
|
||||||
|
for t in vectors:
|
||||||
|
# 2-tuple: (encoded, decoded)
|
||||||
|
# 3-tuple: (encoded, record_nr, decoded) — LinFixedEF / CyclicEF
|
||||||
|
if len(t) >= 3:
|
||||||
|
encoded, record_nr, decoded = t[0], t[1], t[2]
|
||||||
|
comment = f'record {record_nr}: {encoded.lower()}'
|
||||||
|
else:
|
||||||
|
encoded, decoded = t[0], t[1]
|
||||||
|
comment = f'file: {encoded.lower()}'
|
||||||
|
|
||||||
|
json_str = json.dumps(decoded, default=_json_default, indent=2)
|
||||||
|
json_indented = textwrap.indent(json_str, ' ')
|
||||||
|
|
||||||
|
lines.append('.. code-block:: json5\n\n')
|
||||||
|
lines.append(f' // {comment}\n')
|
||||||
|
lines.append(json_indented + '\n')
|
||||||
|
lines.append('\n')
|
||||||
|
|
||||||
|
return ''.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_ef(ef: CardEF) -> str:
|
||||||
|
"""Return RST for a single EF. Uses ``rubric`` to stay out of the TOC."""
|
||||||
|
cls = type(ef)
|
||||||
|
|
||||||
|
parts = [ef.fully_qualified_path_str()]
|
||||||
|
if ef.fid:
|
||||||
|
parts.append(f'({ef.fid.upper()})')
|
||||||
|
if ef.desc:
|
||||||
|
parts.append(f'\u2014 {ef.desc}') # em-dash
|
||||||
|
title = ' '.join(parts)
|
||||||
|
|
||||||
|
lines = [f'.. rubric:: {title}\n\n']
|
||||||
|
|
||||||
|
# Only show a docstring if it is specific to this class. EFs that are
|
||||||
|
# direct instances of a base type (TransparentEF, LinFixedEF, ...) carry
|
||||||
|
# only the generic "what is a TransparentEF" boilerplate; named subclasses
|
||||||
|
# without their own __doc__ have cls.__dict__['__doc__'] == None. Either
|
||||||
|
# way, suppress the text here - it belongs at the document level, not
|
||||||
|
# repeated for every single EF entry.
|
||||||
|
doc = None if cls in _EF_BASE_TYPES else cls.__dict__.get('__doc__')
|
||||||
|
if doc:
|
||||||
|
lines.append(inspect.cleandoc(doc) + '\n\n')
|
||||||
|
|
||||||
|
examples = _examples_block(cls)
|
||||||
|
if examples:
|
||||||
|
lines.append(examples)
|
||||||
|
|
||||||
|
return ''.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_df(df: CardDF, level: int) -> str:
|
||||||
|
"""Return RST for a DF section and all its children, recursively."""
|
||||||
|
parts = [df.fully_qualified_path_str()]
|
||||||
|
if df.fid:
|
||||||
|
parts.append(f'({df.fid.upper()})')
|
||||||
|
if df.desc:
|
||||||
|
parts.append(f'\u2014 {df.desc}') # em-dash
|
||||||
|
title = ' '.join(parts)
|
||||||
|
|
||||||
|
lines = [_heading(title, level)]
|
||||||
|
|
||||||
|
cls = type(df)
|
||||||
|
doc = None if cls in (CardDF, CardMF) else cls.__dict__.get('__doc__')
|
||||||
|
if doc:
|
||||||
|
lines.append(inspect.cleandoc(doc) + '\n\n')
|
||||||
|
|
||||||
|
for child in df.children.values():
|
||||||
|
if isinstance(child, CardDF):
|
||||||
|
lines.append(_document_df(child, level + 1))
|
||||||
|
elif isinstance(child, CardEF):
|
||||||
|
lines.append(_document_ef(child))
|
||||||
|
|
||||||
|
return ''.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Top-level generator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def generate_filesystem_rst() -> str:
|
||||||
|
"""Walk all registered sections and return the full RST document as a string."""
|
||||||
|
out = [
|
||||||
|
'.. This file is auto-generated by docs/pysim_fs_sphinx.py — do not edit.\n\n',
|
||||||
|
_heading('Card Filesystem Reference', 0),
|
||||||
|
'This page documents all Elementary Files (EFs) and Dedicated Files (DFs) '
|
||||||
|
'implemented in pySim, organised by their location in the card filesystem.\n\n',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Track already-documented classes so that DFs/EFs shared between profiles
|
||||||
|
# (e.g. DF.TELECOM / DF.GSM present in both CardProfileSIM and CardProfileRUIM)
|
||||||
|
# are only emitted once.
|
||||||
|
seen_types: set = set()
|
||||||
|
|
||||||
|
for section_title, module_path, class_name in SECTIONS:
|
||||||
|
module = importlib.import_module(module_path)
|
||||||
|
cls = getattr(module, class_name)
|
||||||
|
obj = cls()
|
||||||
|
|
||||||
|
if isinstance(obj, CardProfile):
|
||||||
|
files = obj.files_in_mf
|
||||||
|
elif isinstance(obj, CardApplication):
|
||||||
|
files = list(obj.adf.children.values())
|
||||||
|
elif isinstance(obj, CardDF):
|
||||||
|
files = list(obj.children.values())
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter out files whose class was already documented in an earlier section.
|
||||||
|
files = [f for f in files if type(f) not in seen_types]
|
||||||
|
if not files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
out.append(_heading(section_title, 1))
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
seen_types.add(type(f))
|
||||||
|
if isinstance(f, CardDF):
|
||||||
|
out.append(_document_df(f, level=2))
|
||||||
|
elif isinstance(f, CardEF):
|
||||||
|
out.append(_document_ef(f))
|
||||||
|
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sphinx integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_builder_inited(app):
|
||||||
|
output_path = os.path.join(app.srcdir, 'filesystem.rst')
|
||||||
|
with open(output_path, 'w') as fh:
|
||||||
|
fh.write(generate_filesystem_rst())
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app):
|
||||||
|
app.connect('builder-inited', _on_builder_inited)
|
||||||
|
return {'version': '0.1', 'parallel_read_safe': True}
|
||||||
+1
-1
@@ -67,7 +67,7 @@ Inspecting applications
|
|||||||
|
|
||||||
To inspect the application PE contents of an existing profile package, sub-command `info` with parameter '--apps' can
|
To inspect the application PE contents of an existing profile package, sub-command `info` with parameter '--apps' can
|
||||||
be used. This command lists out all application and their parameters in detail. This allows an application developer
|
be used. This command lists out all application and their parameters in detail. This allows an application developer
|
||||||
to check if the applet insertaion was carried out as expected.
|
to check if the applet insertion was carried out as expected.
|
||||||
|
|
||||||
Example: Listing applications and their parameters
|
Example: Listing applications and their parameters
|
||||||
::
|
::
|
||||||
|
|||||||
+5
-5
@@ -68,7 +68,7 @@ Usage Examples
|
|||||||
|
|
||||||
suci-tutorial
|
suci-tutorial
|
||||||
cap-tutorial
|
cap-tutorial
|
||||||
|
put_key-tutorial
|
||||||
|
|
||||||
Advanced Topics
|
Advanced Topics
|
||||||
---------------
|
---------------
|
||||||
@@ -602,8 +602,8 @@ This allows for easy interactive modification of records.
|
|||||||
If this command fails before the editor is spawned, it means that the current record contents is not decodable,
|
If this command fails before the editor is spawned, it means that the current record contents is not decodable,
|
||||||
and you should use the :ref:`update_record_decoded` or :ref:`update_record` command.
|
and you should use the :ref:`update_record_decoded` or :ref:`update_record` command.
|
||||||
|
|
||||||
If this command fails after making your modificatiosn in the editor, it means that the new file contents is not
|
If this command fails after making your modifications in the editor, it means that the new file contents is not
|
||||||
encodable; please check your input and/or us the raw :ref:`update_record` comamdn.
|
encodable; please check your input and/or use the raw :ref:`update_record` command.
|
||||||
|
|
||||||
|
|
||||||
decode_hex
|
decode_hex
|
||||||
@@ -708,8 +708,8 @@ This allows for easy interactive modification of file contents.
|
|||||||
If this command fails before the editor is spawned, it means that the current file contents is not decodable,
|
If this command fails before the editor is spawned, it means that the current file contents is not decodable,
|
||||||
and you should use the :ref:`update_binary_decoded` or :ref:`update_binary` command.
|
and you should use the :ref:`update_binary_decoded` or :ref:`update_binary` command.
|
||||||
|
|
||||||
If this command fails after making your modificatiosn in the editor, it means that the new file contents is not
|
If this command fails after making your modifications in the editor, it means that the new file contents is not
|
||||||
encodable; please check your input and/or us the raw :ref:`update_binary` comamdn.
|
encodable; please check your input and/or use the raw :ref:`update_binary` command.
|
||||||
|
|
||||||
|
|
||||||
decode_hex
|
decode_hex
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
smpp-ota-tool
|
||||||
|
=============
|
||||||
|
|
||||||
|
The `smpp-ota-tool` allows users to send OTA SMS messages containing APDU scripts (RFM, RAM) via an SMPP server. The
|
||||||
|
intended audience are developers who want to test/evaluate the OTA SMS interface of a SIM/UICC/eUICC. `smpp-ota-tool`
|
||||||
|
is intended to be used as a companion tool for :ref:`pySim-smpp2sim`, however it should be usable on any other SMPP
|
||||||
|
server (such as a production SMSC of a live cellular network) as well.
|
||||||
|
|
||||||
|
From the technical perspective `smpp-ota-tool` takes the role of an SMPP ESME. It takes care of the encoding, encryption
|
||||||
|
and checksumming (signing) of the RFM/RAM OTA SMS and eventually submits it to the SMPP server. The program then waits
|
||||||
|
for a response. The response is automatically parsed and printed on stdout. This makes the program also suitable to be
|
||||||
|
called from shell scripts.
|
||||||
|
|
||||||
|
.. note:: In the following we will we will refer to `SIM` as one of the following: `SIM`, `USIM`, `ISIM`, `UICC`,
|
||||||
|
`eUICC`, `eSIM`.
|
||||||
|
|
||||||
|
Applying OTA keys
|
||||||
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Depending on the `SIM` type you will receive one or more sets of keys which you can use to communicate with the `SIM`
|
||||||
|
through a secure channel protocol. When using the OTA SMS method, the SCP80 protocol is used and it therefore crucial
|
||||||
|
to use a keyset that is actually suitable for SCP80.
|
||||||
|
|
||||||
|
A keyset usually consists of three keys:
|
||||||
|
|
||||||
|
#. KIC: the key used for ciphering (encryption/decryption)
|
||||||
|
#. KID: the key used to compute a cryptographic checksum (signing)
|
||||||
|
#. KIK: the key used to encrypt/decrypt key material (key rotation, adding of new keys)
|
||||||
|
|
||||||
|
From the transport security perspective, only KIC and KID are relevant. The KIK (also referenced as "Data Encryption
|
||||||
|
Key", DEK) is only used when keys are rotated or new keys are added (see also ETSI TS 102 226, section 8.2.1.5).
|
||||||
|
|
||||||
|
When the keyset is programmed into the security domain of the `SIM`, it is tied to a specific cryptographic algorithm
|
||||||
|
(3DES, AES128 or AES256) and a so called Key Version Number (KVN). The term "Key Version Number" is misleading, since
|
||||||
|
it is actually not a version number. It is a unique identifier of a certain keyset which also identifies for which
|
||||||
|
secure channel protocol the keyset may be used. Keysets with a KVN from 1-15 (``0x01``-``0x0F``) are suitable for SCP80.
|
||||||
|
This means that it is not only important to know just the KIC/KID/KIK keys. Also the related algorithms and the KVN
|
||||||
|
numbers must be known.
|
||||||
|
|
||||||
|
.. note:: SCP80 keysets typically start counting from 1 upwards. Typical configurations use a set of 3 keysets with
|
||||||
|
KVN numbers 1-3.
|
||||||
|
|
||||||
|
Addressing an Application
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
When communicating with a specific application on a `SIM` via SCP80, it is important to address that application with
|
||||||
|
the correct parameters. The following two parameters must be known in advance:
|
||||||
|
|
||||||
|
#. TAR: The Toolkit Application Reference (TAR) number is a three byte value that uniquely addresses an application
|
||||||
|
on the `SIM`. The exact values may vary (see also ETSI TS 101 220, Table D.1).
|
||||||
|
#. MSL: The Minimum Security Level (MSL) is a bit-field that dictates which of the security measures encoded in the
|
||||||
|
SPI are mandatory (see also ETSI TS 102 225, section 5.1.1).
|
||||||
|
|
||||||
|
A practical example
|
||||||
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
.. note:: This tutorial assumes that pySim-smpp2sim is running on the local machine with its default parameters.
|
||||||
|
See also :ref:`pySim-smpp2sim`.
|
||||||
|
|
||||||
|
Let's assume that an OTA SMS shall be sent to the SIM RFM application of an sysmoISIM-SJA2. What we want to do is to
|
||||||
|
select DF.GSM and to get the select response back.
|
||||||
|
|
||||||
|
We have received the following key material from the `SIM` vendor:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
KIC1: F09C43EE1A0391665CC9F05AF4E0BD10
|
||||||
|
KID1: 01981F4A20999F62AF99988007BAF6CA
|
||||||
|
KIK1: 8F8AEE5CDCC5D361368BC45673D99195
|
||||||
|
KIC2: 01022916E945B656FDE03F806A105FA2
|
||||||
|
KID2: D326CB69F160333CC5BD1495D448EFD6
|
||||||
|
KIK2: 08037E0590DFE049D4975FFB8652F625
|
||||||
|
KIC3: 2B22824D0D27A3A1CEEC512B312082B4
|
||||||
|
KID3: F1697766925A11F4458295590137B672
|
||||||
|
KIK3: C7EE69B2C5A1C8E160DD36A38EB517B3
|
||||||
|
|
||||||
|
Those are three keysets. The enumeration is directly equal to the KVN used. All three keysets are 3DES keys, which
|
||||||
|
means triple_des_cbc2 is the correct algorithm to use.
|
||||||
|
|
||||||
|
.. note:: The key set configuration can be confirmed by retrieving the key configuration using
|
||||||
|
`get_data key_information` from within an SCP02 session on ADF.ISD.
|
||||||
|
|
||||||
|
In this example we intend to address the SIM RFM application on the `SIM`. Which according to the manual has TAR ``B00010``
|
||||||
|
and MSL ``0x06``. When we hold ``0x06`` = ``0b00000110`` against the SPI coding chart (see also ETSI TS 102 225,
|
||||||
|
section 5.1.1). We can deduct that Ciphering and Cryptographic Checksum are mandatory.
|
||||||
|
|
||||||
|
.. note:: The MSL (see also ETSI TS 102 226, section 6.1) is assigned to an application by the `SIM` issuer. It is a
|
||||||
|
custom decision and may vary with different `SIM` types/profiles. In the case of sysmoISIM-SJS1/SJA2/SJA5 the
|
||||||
|
counter requirement has been waived to simplify lab/research type use. In productive environments, `SIM`
|
||||||
|
applications should ideally use an MSL that makes the counter mandatory.
|
||||||
|
|
||||||
|
In order to select DF.GSM (``0x7F20``) and to retrieve the select response, two APDUs are needed. The first APDU is the
|
||||||
|
select command ``A0A40000027F20`` and the second is the related get-response command ``A0C0000016``. Those APDUs will be
|
||||||
|
concatenated and are sent in a single message. The message containing the concatenated APDUs works as a script that
|
||||||
|
is received by the SIM RFM application and then executed. This method poses some limitations that have to be taken into
|
||||||
|
account when making requests like this (see also ETSI TS 102 226, section 5).
|
||||||
|
|
||||||
|
With this information we may now construct a commandline for `smpp-ota-tool.py`. We will pass the KVN as kid_idx and
|
||||||
|
kic_idx (see also ETSI TS 102 225, Table 2, fields `KIc` and `KID`). Both index values should refer to the same
|
||||||
|
keyset/KVN as keysets should not be mixed. (`smpp-ota-tool` still provides separate parameters anyway to allow testing
|
||||||
|
with invalid keyset combinations)
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic F09C43EE1A0391665CC9F05AF4E0BD10 --kid 01981F4A20999F62AF99988107BAF6CA --kid_idx 1 --kic_idx 1 --algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar B00010 --apdu A0A40000027F20 --apdu A0C0000016
|
||||||
|
2026-02-26 17:13:56 INFO Connecting to localhost:2775...
|
||||||
|
2026-02-26 17:13:56 INFO C-APDU sending: a0a40000027f20a0c0000016...
|
||||||
|
2026-02-26 17:13:56 INFO SMS-TPDU sending: 02700000281506191515b00010da1d6cbbd0d11ce4330d844c7408340943e843f67a6d7b0674730881605fd62d...
|
||||||
|
2026-02-26 17:13:56 INFO SMS-TPDU sent, waiting for response...
|
||||||
|
2026-02-26 17:13:56 INFO SMS-TPDU received: 027100002c12b000107ddf58d1780f771638b3975759f4296cf5c31efc87a16a1b61921426baa16da1b5ba1a9951d59a39
|
||||||
|
2026-02-26 17:13:56 INFO SMS-TPDU decoded: (Container(rpl=44, rhl=18, tar=b'\xb0\x00\x10', cntr=b'\x00\x00\x00\x00\x00', pcntr=0, response_status=uEnumIntegerString.new(0, 'por_ok'), cc_rc=b'\x8f\xea\xf5.\xf4\x0e\xc2\x14', secured_data=b'\x02\x90\x00\x00\x00\xff\xff\x7f \x02\x00\x00\x00\x00\x00\t\xb1\x065\x04\x00\x83\x8a\x83\x8a'), Container(number_of_commands=2, last_status_word=u'9000', last_response_data=u'0000ffff7f2002000000000009b106350400838a838a'))
|
||||||
|
2026-02-26 17:13:56 INFO R-APDU received: 0000ffff7f2002000000000009b106350400838a838a 9000
|
||||||
|
0000ffff7f2002000000000009b106350400838a838a 9000
|
||||||
|
2026-02-26 17:13:56 INFO Disconnecting...
|
||||||
|
|
||||||
|
The result we see is the select response of DF.GSM and a status word indicating that the last command has been
|
||||||
|
processed normally.
|
||||||
|
|
||||||
|
As we can see, this mechanism now allows us to perform small administrative tasks remotely. We can read the contents of
|
||||||
|
files remotely or make changes to files. Depending on the changes we make, there may be security issues arising from
|
||||||
|
replay attacks. With the commandline above, the communication is encrypted and protected by a cryptographic checksum,
|
||||||
|
so an adversary can neither read, nor alter the message. However, an adversary could still replay an intercepted
|
||||||
|
message and the `SIM` would happily execute the contained APDUs again.
|
||||||
|
|
||||||
|
To prevent this, we may include a replay protection counter within the message. In this case, the MSL indicates that a
|
||||||
|
replay protection counter is not required. However, to extended the security of our messages, we may chose to use a
|
||||||
|
counter anyway. In the following example, we will encode a counter value of 100. We will instruct the `SIM` to make sure
|
||||||
|
that the value we send is higher than the counter value that is currently stored in the `SIM`.
|
||||||
|
|
||||||
|
To add a replay connection counter we add the commandline arguments `--cntr-req` to set the counter requirement and
|
||||||
|
`--cntr` to pass the counter value.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic F09C43EE1A0391665CC9F05AF4E0BD10 --kid 01981F4A20999F62AF99988107BAF6CA --kid_idx 1 --kic_idx 1 --algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar B00010 --apdu A0A40000027F20 --apdu A0C0000016 --cntr-req counter_must_be_higher --cntr 100
|
||||||
|
2026-02-26 17:16:39 INFO Connecting to localhost:2775...
|
||||||
|
2026-02-26 17:16:39 INFO C-APDU sending: a0a40000027f20a0c0000016...
|
||||||
|
2026-02-26 17:16:39 INFO SMS-TPDU sending: 02700000281516191515b000103a4f599e94f2b5dcfbbda984761b7977df6514c57a580fb4844787c436d2eade...
|
||||||
|
2026-02-26 17:16:39 INFO SMS-TPDU sent, waiting for response...
|
||||||
|
2026-02-26 17:16:39 INFO SMS-TPDU received: 027100002c12b0001049fb0315f6c6401b553867f412cefaf9355b38271178edb342a3bc9cc7e670cdc1f45eea6ffcbb39
|
||||||
|
2026-02-26 17:16:39 INFO SMS-TPDU decoded: (Container(rpl=44, rhl=18, tar=b'\xb0\x00\x10', cntr=b'\x00\x00\x00\x00d', pcntr=0, response_status=uEnumIntegerString.new(0, 'por_ok'), cc_rc=b'\xa9/\xc7\xc9\x00"\xab5', secured_data=b'\x02\x90\x00\x00\x00\xff\xff\x7f \x02\x00\x00\x00\x00\x00\t\xb1\x065\x04\x00\x83\x8a\x83\x8a'), Container(number_of_commands=2, last_status_word=u'9000', last_response_data=u'0000ffff7f2002000000000009b106350400838a838a'))
|
||||||
|
2026-02-26 17:16:39 INFO R-APDU received: 0000ffff7f2002000000000009b106350400838a838a 9000
|
||||||
|
0000ffff7f2002000000000009b106350400838a838a 9000
|
||||||
|
2026-02-26 17:16:39 INFO Disconnecting...
|
||||||
|
|
||||||
|
The `SIM` has accepted the message. The message got processed and the `SIM` has set its internal to 100. As an experiment,
|
||||||
|
we may try to re-use the counter value:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic F09C43EE1A0391665CC9F05AF4E0BD10 --kid 01981F4A20999F62AF99988107BAF6CA --kid_idx 1 --kic_idx 1 --algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar B00010 --apdu A0A40000027F20 --apdu A0C0000016 --cntr-req counter_must_be_higher --cntr 100
|
||||||
|
2026-02-26 17:16:43 INFO Connecting to localhost:2775...
|
||||||
|
2026-02-26 17:16:43 INFO C-APDU sending: a0a40000027f20a0c0000016...
|
||||||
|
2026-02-26 17:16:43 INFO SMS-TPDU sending: 02700000281516191515b000103a4f599e94f2b5dcfbbda984761b7977df6514c57a580fb4844787c436d2eade...
|
||||||
|
2026-02-26 17:16:43 INFO SMS-TPDU sent, waiting for response...
|
||||||
|
2026-02-26 17:16:43 INFO SMS-TPDU received: 027100000b0ab0001000000000000006
|
||||||
|
2026-02-26 17:16:43 INFO SMS-TPDU decoded: (Container(rpl=11, rhl=10, tar=b'\xb0\x00\x10', cntr=b'\x00\x00\x00\x00\x00', pcntr=0, response_status=uEnumIntegerString.new(6, 'undefined_security_error'), cc_rc=b'', secured_data=b''), None)
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/home/user/work/git_master/pysim/./contrib/smpp-ota-tool.py", line 238, in <module>
|
||||||
|
resp, sw = smpp_handler.transceive_apdu(apdu, opts.src_addr, opts.dest_addr, opts.timeout)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/home/user/work/git_master/pysim/./contrib/smpp-ota-tool.py", line 162, in transceive_apdu
|
||||||
|
raise ValueError("Response does not contain any last_response_data, no R-APDU received!")
|
||||||
|
ValueError: Response does not contain any last_response_data, no R-APDU received!
|
||||||
|
2026-02-26 17:16:43 INFO Disconnecting...
|
||||||
|
|
||||||
|
As we can see, the `SIM` has rejected the message with an `undefined_security_error`. The replay-protection-counter
|
||||||
|
ensures that a message can only be sent once.
|
||||||
|
|
||||||
|
.. note:: The replay-protection-counter is implemented as a 5 byte integer value (see also ETSI TS 102 225, Table 3).
|
||||||
|
When the counter has reached its maximum, it will not overflow nor can it be reset.
|
||||||
|
|
||||||
|
Expanded remote application data format
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
`smpp-ota-tool` uses the TS 102 226 section 5.1 compact remote application data format by default. This
|
||||||
|
format concatenates C-APDUs into one command string and only the result of the LAST executed command is reported back.
|
||||||
|
Retrieving the response data therefore requires a GET RESPONSE C-APDU, and only a single GET RESPONSE command may occur per script.
|
||||||
|
|
||||||
|
The TS 102 226 section 5.2 expanded remote application data format removes these limitations: Each C-APDU is
|
||||||
|
wrapped in its own C-APDU TLV inside a Command Scripting template, and the response is a Response Scripting template that contains one R-APDU TLV with the full response data and status word per executed command. To use it, pass
|
||||||
|
``--format expanded``; every ``--apdu`` argument then becomes its own C-APDU TLV.
|
||||||
|
|
||||||
|
.. note:: The expanded format does not use GET RESPONSE. To retrieve response data from a case 2 or case 4
|
||||||
|
command, include an ``Le`` field in the C-APDU. i.e. ``Le='00'`` instructs the card to return all available
|
||||||
|
response data in the R-APDU, with no 256-byte limit (TS 102 226, section 5.2.1.1). Without the ``Le``
|
||||||
|
field no response data is returned, except a status word for the last command!.
|
||||||
|
|
||||||
|
For example, a GP GET STATUS of all applications (``80F24002024F00``) returns a registry that can be much
|
||||||
|
larger than 256 bytes. In the compact format the card would only answer with ``61xx`` procedure bytes. In the expanded
|
||||||
|
format, appending ``Le='00'`` (i.e. ``80F24002024F0000``) makes the card return the whole registry in one exchange:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic <KIC> --kid <KID> --kid-idx 1 --kic-idx 1 \
|
||||||
|
--algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar 000000 --cntr-req no_counter \
|
||||||
|
--format expanded --apdu 80F24002024F0000
|
||||||
|
|
||||||
|
The response data (a concatenation of GlobalPlatform registry TLVs) can then be decoded with
|
||||||
|
``pySim.global_platform.GpRegistryRelatedData.from_tlv()``.
|
||||||
|
|
||||||
|
smpp-ota-tool syntax
|
||||||
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
.. argparse::
|
||||||
|
:module: contrib.smpp-ota-tool
|
||||||
|
:func: option_parser
|
||||||
|
:prog: contrib/smpp-ota-tool.py
|
||||||
@@ -55,3 +55,5 @@ And once your external program is sending SMS to the simulated SMSC, it will log
|
|||||||
SMSPPDownload(DeviceIdentities({'source_dev_id': 'network', 'dest_dev_id': 'uicc'}),Address({'ton_npi': 0, 'call_number': '0123456'}),SMS_TPDU({'tpdu': '400290217ff6227052000000002d02700000281516191212b0000127fa28a5bac69d3c5e9df2c7155dfdde449c826b236215566530787b30e8be5d'}))
|
SMSPPDownload(DeviceIdentities({'source_dev_id': 'network', 'dest_dev_id': 'uicc'}),Address({'ton_npi': 0, 'call_number': '0123456'}),SMS_TPDU({'tpdu': '400290217ff6227052000000002d02700000281516191212b0000127fa28a5bac69d3c5e9df2c7155dfdde449c826b236215566530787b30e8be5d'}))
|
||||||
INFO root: ENVELOPE: d147820283818604001032548b3b400290217ff6227052000000002d02700000281516191212b0000127fa28a5bac69d3c5e9df2c7155dfdde449c826b236215566530787b30e8be5d
|
INFO root: ENVELOPE: d147820283818604001032548b3b400290217ff6227052000000002d02700000281516191212b0000127fa28a5bac69d3c5e9df2c7155dfdde449c826b236215566530787b30e8be5d
|
||||||
INFO root: SW 9000: 027100002412b000019a551bb7c28183652de0ace6170d0e563c5e949a3ba56747fe4c1dbbef16642c
|
INFO root: SW 9000: 027100002412b000019a551bb7c28183652de0ace6170d0e563c5e949a3ba56747fe4c1dbbef16642c
|
||||||
|
|
||||||
|
.. note:: for sending OTA SMS messages :ref:`smpp-ota-tool` may be used.
|
||||||
|
|||||||
+55
-4
@@ -136,6 +136,52 @@ from pySim.esim.x509_cert import CertAndPrivkey, CertificateSet, cert_get_subjec
|
|||||||
import logging # noqa: E402
|
import logging # noqa: E402
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_twisted_alpn_if_incompatible():
|
||||||
|
"""Twisted <-> pyOpenSSL TLS compatibility guard applied at import.
|
||||||
|
|
||||||
|
Twisted TLSMemoryBIOFactory applies ALPN by setting the 'select' callback
|
||||||
|
on the SSL Context after it has already created a Connection from that
|
||||||
|
Context (_createConnection -> _applyProtocolNegotiation).
|
||||||
|
pyOpenSSL >= 25.0.0 makes a Context immutable once it has been used and
|
||||||
|
raises, which aborts every inbound TLS handshake, client sees unexpected-EOF
|
||||||
|
/ decode_error that looks like a cert/cipher problem but is not.
|
||||||
|
pyOpenSSL < 25 does not import against recent cryptography, so downgrading
|
||||||
|
it is not a fix.
|
||||||
|
|
||||||
|
This server only speaks HTTP/1.1 anyway, so ALPN negotiation is not
|
||||||
|
needed.
|
||||||
|
"""
|
||||||
|
def _major(v):
|
||||||
|
import re
|
||||||
|
m = re.match(r'\d+', (v or '').strip())
|
||||||
|
return int(m.group()) if m else 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
import OpenSSL
|
||||||
|
except Exception:
|
||||||
|
return # no pyOpenSSL ???
|
||||||
|
pyossl_ver = getattr(OpenSSL, '__version__', '0')
|
||||||
|
if _major(pyossl_ver) < 25:
|
||||||
|
return # pre-25 pyOpenSSL allows mutating a used Context
|
||||||
|
|
||||||
|
try:
|
||||||
|
import twisted
|
||||||
|
from twisted.protocols import tls
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
factory = getattr(tls, 'TLSMemoryBIOFactory', None)
|
||||||
|
if factory is None or not hasattr(factory, '_applyProtocolNegotiation'):
|
||||||
|
return # Twisted already fixed
|
||||||
|
|
||||||
|
factory._applyProtocolNegotiation = lambda self, connection: None
|
||||||
|
logger.warning("Disabled Twisted ALPN negotiation: Twisted %s + "
|
||||||
|
"pyOpenSSL %s are incompatible for it",
|
||||||
|
getattr(twisted, '__version__', '?'), pyossl_ver)
|
||||||
|
|
||||||
|
|
||||||
|
_disable_twisted_alpn_if_incompatible()
|
||||||
|
|
||||||
# HACK: make this configurable
|
# HACK: make this configurable
|
||||||
DATA_DIR = './smdpp-data'
|
DATA_DIR = './smdpp-data'
|
||||||
HOSTNAME = 'testsmdpplus1.example.com' # must match certificates!
|
HOSTNAME = 'testsmdpplus1.example.com' # must match certificates!
|
||||||
@@ -479,7 +525,7 @@ class SmDppHttpServer:
|
|||||||
"""See ES9+ InitiateAuthentication SGP.22 Section 5.6.1"""
|
"""See ES9+ InitiateAuthentication SGP.22 Section 5.6.1"""
|
||||||
# Verify that the received address matches its own SM-DP+ address, where the comparison SHALL be
|
# Verify that the received address matches its own SM-DP+ address, where the comparison SHALL be
|
||||||
# case-insensitive. Otherwise, the SM-DP+ SHALL return a status code "SM-DP+ Address - Refused".
|
# case-insensitive. Otherwise, the SM-DP+ SHALL return a status code "SM-DP+ Address - Refused".
|
||||||
if content['smdpAddress'] != self.server_hostname:
|
if content['smdpAddress'].lower() != self.server_hostname.lower():
|
||||||
raise ApiError('8.8.1', '3.8', 'Invalid SM-DP+ Address')
|
raise ApiError('8.8.1', '3.8', 'Invalid SM-DP+ Address')
|
||||||
|
|
||||||
euiccChallenge = b64decode(content['euiccChallenge'])
|
euiccChallenge = b64decode(content['euiccChallenge'])
|
||||||
@@ -640,7 +686,7 @@ class SmDppHttpServer:
|
|||||||
# look up profile based on matchingID. We simply check if a given file exists for now..
|
# look up profile based on matchingID. We simply check if a given file exists for now..
|
||||||
path = os.path.join(self.upp_dir, matchingId) + '.der'
|
path = os.path.join(self.upp_dir, matchingId) + '.der'
|
||||||
# prevent directory traversal attack
|
# prevent directory traversal attack
|
||||||
if os.path.commonprefix((os.path.realpath(path),self.upp_dir)) != self.upp_dir:
|
if os.path.commonpath((os.path.realpath(path),self.upp_dir)) != self.upp_dir:
|
||||||
raise ApiError('8.2.6', '3.8', 'Refused')
|
raise ApiError('8.2.6', '3.8', 'Refused')
|
||||||
if not os.path.isfile(path) or not os.access(path, os.R_OK):
|
if not os.path.isfile(path) or not os.access(path, os.R_OK):
|
||||||
raise ApiError('8.2.6', '3.8', 'Refused')
|
raise ApiError('8.2.6', '3.8', 'Refused')
|
||||||
@@ -870,12 +916,17 @@ def main(argv):
|
|||||||
action='store_true', default=False)
|
action='store_true', default=False)
|
||||||
parser.add_argument("-m", "--in-memory", help="Use ephermal in-memory session storage (for concurrent runs)",
|
parser.add_argument("-m", "--in-memory", help="Use ephermal in-memory session storage (for concurrent runs)",
|
||||||
action='store_true', default=False)
|
action='store_true', default=False)
|
||||||
|
parser.add_argument("--smdp-address", default=HOSTNAME,
|
||||||
|
help="ES9+ SM-DP+ address advertised, defaults to \"%(default)s\". "
|
||||||
|
"Include the TLS port (e.g. %(default)s:8443) when binding a port other "
|
||||||
|
"than 443, so it matches the address the LPA connects to. "
|
||||||
|
"The TLS certificate identity is unaffected.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
|
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
|
||||||
|
|
||||||
common_cert_path = os.path.join(DATA_DIR, args.certdir)
|
common_cert_path = os.path.join(DATA_DIR, args.certdir)
|
||||||
hs = SmDppHttpServer(server_hostname=HOSTNAME, ci_certs_path=os.path.join(common_cert_path, 'CertificateIssuer'), common_cert_path=common_cert_path, use_brainpool=args.brainpool)
|
hs = SmDppHttpServer(server_hostname=args.smdp_address, ci_certs_path=os.path.join(common_cert_path, 'CertificateIssuer'), common_cert_path=common_cert_path, use_brainpool=args.brainpool)
|
||||||
if(args.nossl):
|
if(args.nossl):
|
||||||
hs.app.run(args.host, args.port)
|
hs.app.run(args.host, args.port)
|
||||||
else:
|
else:
|
||||||
@@ -904,7 +955,7 @@ def main(argv):
|
|||||||
with open(cert_pempath, 'wb') as pem_file:
|
with open(cert_pempath, 'wb') as pem_file:
|
||||||
pem_file.write(pem_cert)
|
pem_file.write(pem_cert)
|
||||||
|
|
||||||
SERVER_STRING = f'ssl:{args.port}:privateKey={cert_skpath}:certKey={cert_pempath}:dhParameters={dhparam_path}'
|
SERVER_STRING = f'ssl:{args.port}:privateKey={cert_skpath}:certKey={cert_pempath}:dhParameters={dhparam_path}:interface={args.host}'
|
||||||
print(SERVER_STRING)
|
print(SERVER_STRING)
|
||||||
|
|
||||||
hs.app.run(host=HOSTNAME, port=args.port, endpoint_description=SERVER_STRING)
|
hs.app.run(host=HOSTNAME, port=args.port, endpoint_description=SERVER_STRING)
|
||||||
|
|||||||
+12
-4
@@ -27,7 +27,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
@@ -44,6 +43,11 @@ from pySim.legacy.ts_51_011 import EF
|
|||||||
from pySim.card_handler import *
|
from pySim.card_handler import *
|
||||||
from pySim.utils import *
|
from pySim.utils import *
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
|
log = PySimLogger.get(Path(__file__).stem)
|
||||||
|
|
||||||
def parse_options():
|
def parse_options():
|
||||||
|
|
||||||
@@ -185,6 +189,7 @@ def parse_options():
|
|||||||
default=False, action="store_true")
|
default=False, action="store_true")
|
||||||
parser.add_argument("--card_handler", dest="card_handler_config", metavar="FILE",
|
parser.add_argument("--card_handler", dest="card_handler_config", metavar="FILE",
|
||||||
help="Use automatic card handling machine")
|
help="Use automatic card handling machine")
|
||||||
|
parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
|
||||||
|
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
|
|
||||||
@@ -430,7 +435,7 @@ def gen_parameters(opts):
|
|||||||
if not re.match('^[0-9a-fA-F]{32}$', ki):
|
if not re.match('^[0-9a-fA-F]{32}$', ki):
|
||||||
raise ValueError('Ki needs to be 128 bits, in hex format')
|
raise ValueError('Ki needs to be 128 bits, in hex format')
|
||||||
else:
|
else:
|
||||||
ki = ''.join(['%02x' % random.randrange(0, 256) for i in range(16)])
|
ki = os.urandom(16).hex()
|
||||||
|
|
||||||
# OPC (random)
|
# OPC (random)
|
||||||
if opts.opc is not None:
|
if opts.opc is not None:
|
||||||
@@ -441,7 +446,7 @@ def gen_parameters(opts):
|
|||||||
elif opts.op is not None:
|
elif opts.op is not None:
|
||||||
opc = derive_milenage_opc(ki, opts.op)
|
opc = derive_milenage_opc(ki, opts.op)
|
||||||
else:
|
else:
|
||||||
opc = ''.join(['%02x' % random.randrange(0, 256) for i in range(16)])
|
opc = os.urandom(16).hex()
|
||||||
|
|
||||||
pin_adm = sanitize_pin_adm(opts.pin_adm, opts.pin_adm_hex)
|
pin_adm = sanitize_pin_adm(opts.pin_adm, opts.pin_adm_hex)
|
||||||
|
|
||||||
@@ -770,6 +775,9 @@ if __name__ == '__main__':
|
|||||||
# Parse options
|
# Parse options
|
||||||
opts = parse_options()
|
opts = parse_options()
|
||||||
|
|
||||||
|
# Setup logger
|
||||||
|
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
|
||||||
|
|
||||||
# Init card reader driver
|
# Init card reader driver
|
||||||
sl = init_reader(opts)
|
sl = init_reader(opts)
|
||||||
|
|
||||||
@@ -808,7 +816,7 @@ if __name__ == '__main__':
|
|||||||
print("")
|
print("")
|
||||||
print("Card programming failed with an exception:")
|
print("Card programming failed with an exception:")
|
||||||
print("---------------------8<---------------------")
|
print("---------------------8<---------------------")
|
||||||
traceback.print_exc()
|
print(traceback.format_exc().rstrip())
|
||||||
print("---------------------8<---------------------")
|
print("---------------------8<---------------------")
|
||||||
print("")
|
print("")
|
||||||
rc = -1
|
rc = -1
|
||||||
|
|||||||
+20
-2
@@ -25,7 +25,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -44,12 +43,19 @@ from pySim.exceptions import SwMatchError
|
|||||||
from pySim.legacy.cards import card_detect, SimCard, UsimCard, IsimCard
|
from pySim.legacy.cards import card_detect, SimCard, UsimCard, IsimCard
|
||||||
from pySim.utils import dec_imsi, dec_iccid
|
from pySim.utils import dec_imsi, dec_iccid
|
||||||
from pySim.legacy.utils import format_xplmn_w_act, dec_st, dec_msisdn
|
from pySim.legacy.utils import format_xplmn_w_act, dec_st, dec_msisdn
|
||||||
|
from pySim.ts_51_011 import EF_SMSP
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
|
log = PySimLogger.get(Path(__file__).stem)
|
||||||
|
|
||||||
option_parser = argparse.ArgumentParser(description='Legacy tool for reading some parts of a SIM card',
|
option_parser = argparse.ArgumentParser(description='Legacy tool for reading some parts of a SIM card',
|
||||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||||
|
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
|
||||||
argparse_add_reader_args(option_parser)
|
argparse_add_reader_args(option_parser)
|
||||||
|
|
||||||
|
|
||||||
def select_app(adf: str, card: SimCard):
|
def select_app(adf: str, card: SimCard):
|
||||||
"""Select application by its AID"""
|
"""Select application by its AID"""
|
||||||
sw = 0
|
sw = 0
|
||||||
@@ -74,6 +80,9 @@ if __name__ == '__main__':
|
|||||||
# Parse options
|
# Parse options
|
||||||
opts = option_parser.parse_args()
|
opts = option_parser.parse_args()
|
||||||
|
|
||||||
|
# Setup logger
|
||||||
|
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
|
||||||
|
|
||||||
# Init card reader driver
|
# Init card reader driver
|
||||||
sl = init_reader(opts)
|
sl = init_reader(opts)
|
||||||
|
|
||||||
@@ -141,6 +150,15 @@ if __name__ == '__main__':
|
|||||||
(res, sw) = card.read_record('SMSP', 1)
|
(res, sw) = card.read_record('SMSP', 1)
|
||||||
if sw == '9000':
|
if sw == '9000':
|
||||||
print("SMSP: %s" % (res,))
|
print("SMSP: %s" % (res,))
|
||||||
|
ef_smsp = EF_SMSP()
|
||||||
|
smsc_a = ef_smsp.decode_record_bin(h2b(res), 1).get('tp_sc_addr', {})
|
||||||
|
smsc_n = smsc_a.get('call_number', None)
|
||||||
|
if smsc_a.get('ton_npi', {}).get('type_of_number', None) == 'international' and smsc_n is not None:
|
||||||
|
smsc = '+' + smsc_n
|
||||||
|
else:
|
||||||
|
smsc = smsc_n
|
||||||
|
if smsc is not None:
|
||||||
|
print("SMSC: %s" % (smsc,))
|
||||||
else:
|
else:
|
||||||
print("SMSP: Can't read, response code = %s" % (sw,))
|
print("SMSP: Can't read, response code = %s" % (sw,))
|
||||||
|
|
||||||
|
|||||||
+82
-108
@@ -24,21 +24,21 @@ import traceback
|
|||||||
import re
|
import re
|
||||||
import cmd2
|
import cmd2
|
||||||
from packaging import version
|
from packaging import version
|
||||||
from cmd2 import style
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pySim.log import PySimLogger
|
from pySim.log import PySimLogger
|
||||||
from osmocom.utils import auto_uint8
|
from osmocom.utils import auto_uint8
|
||||||
|
|
||||||
# cmd2 >= 2.3.0 has deprecated the bg/fg in favor of Bg/Fg :(
|
# cmd2 >= 3.0 replaced Fg + style() with Color + stylize()
|
||||||
if version.parse(cmd2.__version__) < version.parse("2.3.0"):
|
if version.parse(cmd2.__version__) >= version.parse("3.0.0"):
|
||||||
from cmd2 import fg, bg # pylint: disable=no-name-in-module
|
from cmd2 import Color, stylize # pylint: disable=no-name-in-module
|
||||||
RED = fg.red
|
RED = Color.RED
|
||||||
YELLOW = fg.yellow
|
YELLOW = Color.YELLOW
|
||||||
LIGHT_RED = fg.bright_red
|
LIGHT_RED = Color.BRIGHT_RED
|
||||||
LIGHT_GREEN = fg.bright_green
|
LIGHT_GREEN = Color.BRIGHT_GREEN
|
||||||
|
def style(text, fg=None, bg=None, bold=False): # pylint: disable=function-redefined
|
||||||
|
return stylize(text, fg) if fg else text
|
||||||
else:
|
else:
|
||||||
from cmd2 import Fg, Bg # pylint: disable=no-name-in-module
|
from cmd2 import style, Fg # pylint: disable=no-name-in-module
|
||||||
RED = Fg.RED
|
RED = Fg.RED
|
||||||
YELLOW = Fg.YELLOW
|
YELLOW = Fg.YELLOW
|
||||||
LIGHT_RED = Fg.LIGHT_RED
|
LIGHT_RED = Fg.LIGHT_RED
|
||||||
@@ -69,50 +69,26 @@ from pySim.ts_102_222 import Ts102222Commands
|
|||||||
from pySim.gsm_r import DF_EIRENE
|
from pySim.gsm_r import DF_EIRENE
|
||||||
from pySim.cat import ProactiveCommand
|
from pySim.cat import ProactiveCommand
|
||||||
|
|
||||||
from pySim.card_key_provider import CardKeyProviderCsv
|
from pySim.card_key_provider import card_key_provider_argparse_add_args, card_key_provider_init
|
||||||
from pySim.card_key_provider import card_key_provider_register, card_key_provider_get_field, card_key_provider_get
|
from pySim.card_key_provider import card_key_provider_get_field, card_key_provider_get
|
||||||
|
|
||||||
from pySim.app import init_card
|
from pySim.app import init_card
|
||||||
|
|
||||||
log = PySimLogger.get("main")
|
log = PySimLogger.get(Path(__file__).stem)
|
||||||
|
|
||||||
class Cmd2Compat(cmd2.Cmd):
|
class PysimApp(cmd2.Cmd):
|
||||||
"""Backwards-compatibility wrapper around cmd2.Cmd to support older and newer
|
|
||||||
releases. See https://github.com/python-cmd2/cmd2/blob/master/CHANGELOG.md"""
|
|
||||||
def run_editor(self, file_path: Optional[str] = None) -> None:
|
|
||||||
if version.parse(cmd2.__version__) < version.parse("2.0.0"):
|
|
||||||
return self._run_editor(file_path) # pylint: disable=no-member
|
|
||||||
else:
|
|
||||||
return super().run_editor(file_path) # pylint: disable=no-member
|
|
||||||
|
|
||||||
class Settable2Compat(cmd2.Settable):
|
|
||||||
"""Backwards-compatibility wrapper around cmd2.Settable to support older and newer
|
|
||||||
releases. See https://github.com/python-cmd2/cmd2/blob/master/CHANGELOG.md"""
|
|
||||||
def __init__(self, name, val_type, description, settable_object, **kwargs):
|
|
||||||
if version.parse(cmd2.__version__) < version.parse("2.0.0"):
|
|
||||||
super().__init__(name, val_type, description, **kwargs) # pylint: disable=no-value-for-parameter
|
|
||||||
else:
|
|
||||||
super().__init__(name, val_type, description, settable_object, **kwargs) # pylint: disable=too-many-function-args
|
|
||||||
|
|
||||||
class PysimApp(Cmd2Compat):
|
|
||||||
CUSTOM_CATEGORY = 'pySim Commands'
|
CUSTOM_CATEGORY = 'pySim Commands'
|
||||||
BANNER = """Welcome to pySim-shell!
|
BANNER = """Welcome to pySim-shell!
|
||||||
(C) 2021-2023 by Harald Welte, sysmocom - s.f.m.c. GmbH and contributors
|
(C) 2021-2023 by Harald Welte, sysmocom - s.f.m.c. GmbH and contributors
|
||||||
Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/shell.html """
|
Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/shell.html """
|
||||||
|
|
||||||
def __init__(self, verbose, card, rs, sl, ch, script=None):
|
def __init__(self, verbose, card, rs, sl, ch, script=None):
|
||||||
if version.parse(cmd2.__version__) < version.parse("2.0.0"):
|
|
||||||
kwargs = {'use_ipython': True}
|
|
||||||
else:
|
|
||||||
kwargs = {'include_ipy': True}
|
|
||||||
|
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self._onchange_verbose('verbose', False, self.verbose);
|
|
||||||
|
|
||||||
# pylint: disable=unexpected-keyword-arg
|
|
||||||
super().__init__(persistent_history_file='~/.pysim_shell_history', allow_cli_args=False,
|
|
||||||
auto_load_commands=False, startup_script=script, **kwargs)
|
|
||||||
PySimLogger.setup(self.poutput, {logging.WARN: YELLOW})
|
PySimLogger.setup(self.poutput, {logging.WARN: YELLOW})
|
||||||
|
self._onchange_verbose('verbose', False, self.verbose)
|
||||||
|
|
||||||
|
super().__init__(persistent_history_file='~/.pysim_shell_history', allow_cli_args=False,
|
||||||
|
auto_load_commands=False, startup_script=script, include_ipy=True)
|
||||||
self.intro = style(self.BANNER, fg=RED)
|
self.intro = style(self.BANNER, fg=RED)
|
||||||
self.default_category = 'pySim-shell built-in commands'
|
self.default_category = 'pySim-shell built-in commands'
|
||||||
self.card = None
|
self.card = None
|
||||||
@@ -125,22 +101,27 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
self.numeric_path = False
|
self.numeric_path = False
|
||||||
self.conserve_write = True
|
self.conserve_write = True
|
||||||
self.json_pretty_print = True
|
self.json_pretty_print = True
|
||||||
self.apdu_trace = False
|
self.apdu_trace = getattr(sl, 'apdu_tracer', None) is not None
|
||||||
self.apdu_strict = False
|
self.apdu_strict = False
|
||||||
|
|
||||||
self.add_settable(Settable2Compat('numeric_path', bool, 'Print File IDs instead of names', self,
|
self.add_settable(cmd2.Settable('numeric_path', bool,
|
||||||
onchange_cb=self._onchange_numeric_path))
|
'Print File IDs instead of names',
|
||||||
self.add_settable(Settable2Compat('conserve_write', bool, 'Read and compare before write', self,
|
self, onchange_cb=self._onchange_numeric_path))
|
||||||
onchange_cb=self._onchange_conserve_write))
|
self.add_settable(cmd2.Settable('conserve_write', bool,
|
||||||
self.add_settable(Settable2Compat('json_pretty_print', bool, 'Pretty-Print JSON output', self))
|
'Read and compare before write',
|
||||||
self.add_settable(Settable2Compat('apdu_trace', bool, 'Trace and display APDUs exchanged with card', self,
|
self, onchange_cb=self._onchange_conserve_write))
|
||||||
onchange_cb=self._onchange_apdu_trace))
|
self.add_settable(cmd2.Settable('json_pretty_print', bool,
|
||||||
self.add_settable(Settable2Compat('apdu_strict', bool,
|
'Pretty-Print JSON output',
|
||||||
'Enforce APDU responses according to ISO/IEC 7816-3, table 12', self,
|
self))
|
||||||
onchange_cb=self._onchange_apdu_strict))
|
self.add_settable(cmd2.Settable('apdu_trace', bool,
|
||||||
self.add_settable(Settable2Compat('verbose', bool,
|
'Trace and display APDUs exchanged with card',
|
||||||
'Enable/disable verbose logging', self,
|
self, onchange_cb=self._onchange_apdu_trace))
|
||||||
onchange_cb=self._onchange_verbose))
|
self.add_settable(cmd2.Settable('apdu_strict', bool,
|
||||||
|
'Strictly apply APDU format according to ISO/IEC 7816-3, table 12',
|
||||||
|
self))
|
||||||
|
self.add_settable(cmd2.Settable('verbose', bool,
|
||||||
|
'Enable/disable verbose logging',
|
||||||
|
self, onchange_cb=self._onchange_verbose))
|
||||||
self.equip(card, rs)
|
self.equip(card, rs)
|
||||||
|
|
||||||
def equip(self, card, rs):
|
def equip(self, card, rs):
|
||||||
@@ -218,13 +199,6 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
else:
|
else:
|
||||||
self.card._scc._tp.apdu_tracer = None
|
self.card._scc._tp.apdu_tracer = None
|
||||||
|
|
||||||
def _onchange_apdu_strict(self, param_name, old, new):
|
|
||||||
if self.card:
|
|
||||||
if new == True:
|
|
||||||
self.card._scc._tp.apdu_strict = True
|
|
||||||
else:
|
|
||||||
self.card._scc._tp.apdu_strict = False
|
|
||||||
|
|
||||||
def _onchange_verbose(self, param_name, old, new):
|
def _onchange_verbose(self, param_name, old, new):
|
||||||
PySimLogger.set_verbose(new)
|
PySimLogger.set_verbose(new)
|
||||||
if new == True:
|
if new == True:
|
||||||
@@ -236,8 +210,10 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
def __init__(self, cmd2_app):
|
def __init__(self, cmd2_app):
|
||||||
self.cmd2 = cmd2_app
|
self.cmd2 = cmd2_app
|
||||||
|
|
||||||
def trace_response(self, cmd, sw, resp):
|
def trace_command(self, cmd):
|
||||||
self.cmd2.poutput("-> %s %s" % (cmd[:10], cmd[10:]))
|
self.cmd2.poutput("-> %s %s" % (cmd[:10], cmd[10:]))
|
||||||
|
|
||||||
|
def trace_response(self, cmd, sw, resp):
|
||||||
self.cmd2.poutput("<- %s: %s" % (sw, resp))
|
self.cmd2.poutput("<- %s: %s" % (sw, resp))
|
||||||
|
|
||||||
def update_prompt(self):
|
def update_prompt(self):
|
||||||
@@ -281,7 +257,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
apdu_cmd_parser.add_argument('--expect-sw', help='expect a specified status word', type=str, default=None)
|
apdu_cmd_parser.add_argument('--expect-sw', help='expect a specified status word', type=str, default=None)
|
||||||
apdu_cmd_parser.add_argument('--expect-response-regex', help='match response against regex', type=str, default=None)
|
apdu_cmd_parser.add_argument('--expect-response-regex', help='match response against regex', type=str, default=None)
|
||||||
apdu_cmd_parser.add_argument('--raw', help='Bypass the logical channel (and secure channel)', action='store_true')
|
apdu_cmd_parser.add_argument('--raw', help='Bypass the logical channel (and secure channel)', action='store_true')
|
||||||
apdu_cmd_parser.add_argument('APDU', type=is_hexstr, help='APDU as hex string')
|
apdu_cmd_parser.add_argument('APDU', type=is_hexstr, help='APDU as hex string (see also: ISO/IEC 7816-3, section 12.1')
|
||||||
|
|
||||||
@cmd2.with_argparser(apdu_cmd_parser)
|
@cmd2.with_argparser(apdu_cmd_parser)
|
||||||
def do_apdu(self, opts):
|
def do_apdu(self, opts):
|
||||||
@@ -290,14 +266,23 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
tracked. Depending on the raw APDU sent, pySim-shell may not continue to work as expected if you e.g. select
|
tracked. Depending on the raw APDU sent, pySim-shell may not continue to work as expected if you e.g. select
|
||||||
a different file."""
|
a different file."""
|
||||||
|
|
||||||
|
if not hasattr(self, 'apdu_strict_warning_displayed') and self.apdu_strict is False:
|
||||||
|
self.poutput("Warning: The default for the setable parameter `apdu_strict` will be changed from")
|
||||||
|
self.poutput(" `False` to `True` in future pySim-shell releases. In case you are using")
|
||||||
|
self.poutput(" the `apdu` command from a script that still mixes APDUs with TPDUs, consider")
|
||||||
|
self.poutput(" fixing or adding a `set apdu_strict false` line at the beginning.")
|
||||||
|
self.apdu_strict_warning_displayed = True;
|
||||||
|
|
||||||
# When sending raw APDUs we access the scc object through _scc member of the card object. It should also be
|
# When sending raw APDUs we access the scc object through _scc member of the card object. It should also be
|
||||||
# noted that the apdu command plays an exceptional role since it is the only card accessing command that
|
# noted that the apdu command plays an exceptional role since it is the only card accessing command that
|
||||||
# can be executed without the presence of a runtime state (self.rs) object. However, this also means that
|
# can be executed without the presence of a runtime state (self.rs) object. However, this also means that
|
||||||
# self.lchan is also not present (see method equip).
|
# self.lchan is also not present (see method equip).
|
||||||
|
self.card._scc._tp.apdu_strict = self.apdu_strict
|
||||||
if opts.raw or self.lchan is None:
|
if opts.raw or self.lchan is None:
|
||||||
data, sw = self.card._scc.send_apdu(opts.APDU, apply_lchan = False)
|
data, sw = self.card._scc.send_apdu(opts.APDU, apply_lchan = False)
|
||||||
else:
|
else:
|
||||||
data, sw = self.lchan.scc.send_apdu(opts.APDU, apply_lchan = False)
|
data, sw = self.lchan.scc.send_apdu(opts.APDU, apply_lchan = False)
|
||||||
|
self.card._scc._tp.apdu_strict = True
|
||||||
if data:
|
if data:
|
||||||
self.poutput("SW: %s, RESP: %s" % (sw, data))
|
self.poutput("SW: %s, RESP: %s" % (sw, data))
|
||||||
else:
|
else:
|
||||||
@@ -366,7 +351,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
self.poutput("")
|
self.poutput("")
|
||||||
self.poutput("Card initialization (%s) failed with an exception:" % str(self.sl))
|
self.poutput("Card initialization (%s) failed with an exception:" % str(self.sl))
|
||||||
self.poutput("---------------------8<---------------------")
|
self.poutput("---------------------8<---------------------")
|
||||||
traceback.print_exc()
|
self.poutput(traceback.format_exc().rstrip())
|
||||||
self.poutput("---------------------8<---------------------")
|
self.poutput("---------------------8<---------------------")
|
||||||
self.poutput("")
|
self.poutput("")
|
||||||
return -1
|
return -1
|
||||||
@@ -480,7 +465,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
self.poutput("")
|
self.poutput("")
|
||||||
self.poutput("Card handling (%s) failed with an exception:" % str(self.sl))
|
self.poutput("Card handling (%s) failed with an exception:" % str(self.sl))
|
||||||
self.poutput("---------------------8<---------------------")
|
self.poutput("---------------------8<---------------------")
|
||||||
traceback.print_exc()
|
self.poutput(traceback.format_exc().rstrip())
|
||||||
self.poutput("---------------------8<---------------------")
|
self.poutput("---------------------8<---------------------")
|
||||||
self.poutput("")
|
self.poutput("")
|
||||||
fail_count = fail_count + 1
|
fail_count = fail_count + 1
|
||||||
@@ -519,8 +504,17 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
|||||||
@cmd2.with_category(CUSTOM_CATEGORY)
|
@cmd2.with_category(CUSTOM_CATEGORY)
|
||||||
def do_version(self, opts):
|
def do_version(self, opts):
|
||||||
"""Print the pySim software version."""
|
"""Print the pySim software version."""
|
||||||
import pkg_resources
|
from importlib.metadata import version as vsn
|
||||||
self.poutput(pkg_resources.get_distribution('pySim'))
|
self.poutput("pyosmocom " + vsn('pyosmocom'))
|
||||||
|
import os
|
||||||
|
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
if os.path.isdir(os.path.join(cwd, ".git")):
|
||||||
|
import subprocess
|
||||||
|
url = subprocess.check_output(['git', 'config', '--get', 'remote.origin.url']).decode('ascii').strip()
|
||||||
|
version = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=cwd).decode('ascii').strip()
|
||||||
|
self.poutput(os.path.basename(url) + " " + version)
|
||||||
|
else:
|
||||||
|
self.poutput("pySim " + vsn('pySim'))
|
||||||
|
|
||||||
@with_default_category('pySim Commands')
|
@with_default_category('pySim Commands')
|
||||||
class PySimCommands(CommandSet):
|
class PySimCommands(CommandSet):
|
||||||
@@ -1136,20 +1130,14 @@ global_group.add_argument("--skip-card-init", help="Skip all card/profile initia
|
|||||||
global_group.add_argument("--verbose", help="Enable verbose logging",
|
global_group.add_argument("--verbose", help="Enable verbose logging",
|
||||||
action='store_true', default=False)
|
action='store_true', default=False)
|
||||||
|
|
||||||
card_key_group = option_parser.add_argument_group('Card Key Provider Options')
|
|
||||||
card_key_group.add_argument('--csv', metavar='FILE',
|
|
||||||
default=str(Path.home()) + "/.osmocom/pysim/card_data.csv",
|
|
||||||
help='Read card data from CSV file')
|
|
||||||
card_key_group.add_argument('--csv-column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
|
|
||||||
help=argparse.SUPPRESS, dest='column_key')
|
|
||||||
card_key_group.add_argument('--column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
|
|
||||||
help='per-column AES transport key', dest='column_key')
|
|
||||||
|
|
||||||
adm_group = global_group.add_mutually_exclusive_group()
|
adm_group = global_group.add_mutually_exclusive_group()
|
||||||
adm_group.add_argument('-a', '--pin-adm', metavar='PIN_ADM1', dest='pin_adm', default=None,
|
adm_group.add_argument('-a', '--pin-adm', metavar='PIN_ADM', dest='pin_adm', default=None,
|
||||||
help='ADM PIN used for provisioning (overwrites default)')
|
help='ADM PIN used for provisioning (overwrites default)')
|
||||||
adm_group.add_argument('-A', '--pin-adm-hex', metavar='PIN_ADM1_HEX', dest='pin_adm_hex', default=None,
|
adm_group.add_argument('-A', '--pin-adm-hex', metavar='PIN_ADM_HEX', dest='pin_adm_hex', default=None,
|
||||||
help='ADM PIN used for provisioning, as hex string (16 characters long)')
|
help='ADM PIN used for provisioning, as hex string (16 characters long)')
|
||||||
|
global_group.add_argument('--pin-adm-type',
|
||||||
|
choices=[x for x in pin_names.values() if x.startswith('ADM')],
|
||||||
|
help='Override ADM number. Default is card-model-specific, usually 1')
|
||||||
|
|
||||||
option_parser.add_argument('-e', '--execute-command', action='append', default=[],
|
option_parser.add_argument('-e', '--execute-command', action='append', default=[],
|
||||||
help='A pySim-shell command that will be executed at startup')
|
help='A pySim-shell command that will be executed at startup')
|
||||||
@@ -1157,28 +1145,17 @@ option_parser.add_argument("command", nargs='?',
|
|||||||
help="A pySim-shell command that would optionally be executed at startup")
|
help="A pySim-shell command that would optionally be executed at startup")
|
||||||
option_parser.add_argument('command_args', nargs=argparse.REMAINDER,
|
option_parser.add_argument('command_args', nargs=argparse.REMAINDER,
|
||||||
help="Optional Arguments for command")
|
help="Optional Arguments for command")
|
||||||
|
card_key_provider_argparse_add_args(option_parser)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
startup_errors = False
|
startup_errors = False
|
||||||
opts = option_parser.parse_args()
|
opts = option_parser.parse_args()
|
||||||
|
|
||||||
# Ensure that we are able to print formatted warnings from the beginning.
|
# Ensure that we are able to print formatted warnings from the beginning.
|
||||||
PySimLogger.setup(print, {logging.WARN: YELLOW})
|
PySimLogger.setup(print, {logging.WARN: YELLOW}, opts.verbose)
|
||||||
if (opts.verbose):
|
|
||||||
PySimLogger.set_verbose(True)
|
|
||||||
PySimLogger.set_level(logging.DEBUG)
|
|
||||||
else:
|
|
||||||
PySimLogger.set_verbose(False)
|
|
||||||
PySimLogger.set_level(logging.INFO)
|
|
||||||
|
|
||||||
# Register csv-file as card data provider, either from specified CSV
|
# Init card key provider for automatic card key retrieval
|
||||||
# or from CSV file in home directory
|
card_key_provider_init(opts)
|
||||||
column_keys = {}
|
|
||||||
for par in opts.column_key:
|
|
||||||
name, key = par.split(':')
|
|
||||||
column_keys[name] = key
|
|
||||||
if os.path.isfile(opts.csv):
|
|
||||||
card_key_provider_register(CardKeyProviderCsv(opts.csv, column_keys))
|
|
||||||
|
|
||||||
# Init card reader driver
|
# Init card reader driver
|
||||||
sl = init_reader(opts, proactive_handler = Proact())
|
sl = init_reader(opts, proactive_handler = Proact())
|
||||||
@@ -1199,7 +1176,7 @@ if __name__ == '__main__':
|
|||||||
startup_errors = True
|
startup_errors = True
|
||||||
print("Card initialization (%s) failed with an exception:" % str(sl))
|
print("Card initialization (%s) failed with an exception:" % str(sl))
|
||||||
print("---------------------8<---------------------")
|
print("---------------------8<---------------------")
|
||||||
traceback.print_exc()
|
print(traceback.format_exc().rstrip())
|
||||||
print("---------------------8<---------------------")
|
print("---------------------8<---------------------")
|
||||||
if not opts.noprompt:
|
if not opts.noprompt:
|
||||||
print("(you may still try to recover from this manually by using the 'equip' command.)")
|
print("(you may still try to recover from this manually by using the 'equip' command.)")
|
||||||
@@ -1210,18 +1187,15 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
# If the user supplies an ADM PIN at via commandline args authenticate
|
# If the user supplies an ADM PIN at via commandline args authenticate
|
||||||
# immediately so that the user does not have to use the shell commands
|
# immediately so that the user does not have to use the shell commands
|
||||||
pin_adm = sanitize_pin_adm(opts.pin_adm, opts.pin_adm_hex)
|
pin_adm_type = ""
|
||||||
if pin_adm:
|
if opts.pin_adm_type:
|
||||||
if not card:
|
pin_adm_type = "--adm-type %s" % opts.pin_adm_type
|
||||||
print("Card error, cannot do ADM verification with supplied ADM pin now.")
|
if opts.pin_adm:
|
||||||
try:
|
app.onecmd_plus_hooks("verify_adm %s %s" %
|
||||||
card._scc.verify_chv(card._adm_chv_num, h2b(pin_adm))
|
(opts.pin_adm, pin_adm_type), add_to_history = False)
|
||||||
except Exception as e:
|
elif opts.pin_adm_hex:
|
||||||
startup_errors = True
|
app.onecmd_plus_hooks("verify_adm %s --pin-is-hex %s" %
|
||||||
print("ADM verification (%s) failed with an exception:" % str(pin_adm))
|
(opts.pin_adm_hex, pin_adm_type), add_to_history = False)
|
||||||
print("---------------------8<---------------------")
|
|
||||||
print(e)
|
|
||||||
print("---------------------8<---------------------")
|
|
||||||
|
|
||||||
# Run optional commands
|
# Run optional commands
|
||||||
for c in opts.execute_command:
|
for c in opts.execute_command:
|
||||||
|
|||||||
+33
-227
@@ -30,10 +30,13 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import colorlog
|
import colorlog
|
||||||
|
|
||||||
from twisted.protocols import basic
|
from twisted.protocols import basic
|
||||||
from twisted.internet import defer, endpoints, protocol, reactor, task
|
from twisted.internet import defer, endpoints, reactor, task
|
||||||
from twisted.cred.portal import IRealm
|
from twisted.cred.portal import IRealm
|
||||||
from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
|
from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
|
||||||
from twisted.cred.portal import Portal
|
from twisted.cred.portal import Portal
|
||||||
@@ -47,13 +50,16 @@ from smpp.pdu import pdu_types, operations, pdu_encoding
|
|||||||
|
|
||||||
from pySim.sms import SMS_DELIVER, SMS_SUBMIT, AddressField
|
from pySim.sms import SMS_DELIVER, SMS_SUBMIT, AddressField
|
||||||
|
|
||||||
|
from pySim.bip import Proact, terminal_profile
|
||||||
from pySim.transport import LinkBase, ProactiveHandler, argparse_add_reader_args, init_reader, ApduTracer
|
from pySim.transport import LinkBase, ProactiveHandler, argparse_add_reader_args, init_reader, ApduTracer
|
||||||
from pySim.commands import SimCardCommands
|
from pySim.commands import SimCardCommands
|
||||||
from pySim.cards import UiccCardBase
|
from pySim.cards import UiccCardBase
|
||||||
from pySim.exceptions import *
|
from pySim.exceptions import *
|
||||||
|
from pySim.cat import sms_pp_download_envelope
|
||||||
from pySim.cat import ProactiveCommand, SendShortMessage, SMS_TPDU, SMSPPDownload, BearerDescription
|
from pySim.cat import ProactiveCommand, SendShortMessage, SMS_TPDU, SMSPPDownload, BearerDescription
|
||||||
from pySim.cat import DeviceIdentities, Address, OtherAddress, UiccTransportLevel, BufferSize
|
from pySim.cat import DeviceIdentities, Address, OtherAddress, UiccTransportLevel, BufferSize
|
||||||
from pySim.cat import ChannelStatus, ChannelData, ChannelDataLength
|
from pySim.cat import ChannelStatus, ChannelData, ChannelDataLength
|
||||||
|
from pySim.cat import EventList, EventDownload, Result
|
||||||
from pySim.utils import b2h, h2b
|
from pySim.utils import b2h, h2b
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -71,224 +77,6 @@ class MyApduTracer(ApduTracer):
|
|||||||
print("-> %s %s" % (cmd[:10], cmd[10:]))
|
print("-> %s %s" % (cmd[:10], cmd[10:]))
|
||||||
print("<- %s: %s" % (sw, resp))
|
print("<- %s: %s" % (sw, resp))
|
||||||
|
|
||||||
class TcpProtocol(protocol.Protocol):
|
|
||||||
def dataReceived(self, data):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def connectionLost(self, reason):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_connected_callback(p: protocol.Protocol):
|
|
||||||
"""called by twisted TCP client."""
|
|
||||||
logger.error("%s: connected!" % p)
|
|
||||||
|
|
||||||
class ProactChannel:
|
|
||||||
"""Representation of a single protective channel."""
|
|
||||||
def __init__(self, channels: 'ProactChannels', chan_nr: int):
|
|
||||||
self.channels = channels
|
|
||||||
self.chan_nr = chan_nr
|
|
||||||
self.ep = None
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""Close the channel."""
|
|
||||||
if self.ep:
|
|
||||||
self.ep.disconnect()
|
|
||||||
self.channels.channel_delete(self.chan_nr)
|
|
||||||
|
|
||||||
class ProactChannels:
|
|
||||||
"""Wrapper class for maintaining state of proactive channels."""
|
|
||||||
def __init__(self):
|
|
||||||
self.channels = {}
|
|
||||||
|
|
||||||
def channel_create(self) -> ProactChannel:
|
|
||||||
"""Create a new proactive channel, allocating its integer number."""
|
|
||||||
for i in range(1, 9):
|
|
||||||
if not i in self.channels:
|
|
||||||
self.channels[i] = ProactChannel(self, i)
|
|
||||||
return self.channels[i]
|
|
||||||
raise ValueError('Cannot allocate another channel: All channels active')
|
|
||||||
|
|
||||||
def channel_delete(self, chan_nr: int):
|
|
||||||
del self.channels[chan_nr]
|
|
||||||
|
|
||||||
class Proact(ProactiveHandler):
|
|
||||||
#def __init__(self, smpp_factory):
|
|
||||||
# self.smpp_factory = smpp_factory
|
|
||||||
def __init__(self):
|
|
||||||
self.channels = ProactChannels()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _find_first_element_of_type(instlist, cls):
|
|
||||||
for i in instlist:
|
|
||||||
if isinstance(i, cls):
|
|
||||||
return i
|
|
||||||
return None
|
|
||||||
|
|
||||||
"""Call-back which the pySim transport core calls whenever it receives a
|
|
||||||
proactive command from the SIM."""
|
|
||||||
def handle_SendShortMessage(self, pcmd: ProactiveCommand):
|
|
||||||
# {'smspp_download': [{'device_identities': {'source_dev_id': 'network',
|
|
||||||
# 'dest_dev_id': 'uicc'}},
|
|
||||||
# {'address': {'ton_npi': {'ext': True,
|
|
||||||
# 'type_of_number': 'international',
|
|
||||||
# 'numbering_plan_id': 'isdn_e164'},
|
|
||||||
# 'call_number': '79'}},
|
|
||||||
# {'sms_tpdu': {'tpdu': '40048111227ff6407070611535004d02700000481516011212000001fe4c0943aea42e45021c078ae06c66afc09303608874b72f58bacadb0dcf665c29349c799fbb522e61709c9baf1890015e8e8e196e36153106c8b92f95153774'}}
|
|
||||||
# ]}
|
|
||||||
"""Card requests sending a SMS. We need to pass it on to the ESME via SMPP."""
|
|
||||||
logger.info("SendShortMessage")
|
|
||||||
logger.info(pcmd)
|
|
||||||
# Relevant parts in pcmd: Address, SMS_TPDU
|
|
||||||
addr_ie = Proact._find_first_element_of_type(pcmd.children, Address)
|
|
||||||
sms_tpdu_ie = Proact._find_first_element_of_type(pcmd.children, SMS_TPDU)
|
|
||||||
raw_tpdu = sms_tpdu_ie.decoded['tpdu']
|
|
||||||
submit = SMS_SUBMIT.from_bytes(raw_tpdu)
|
|
||||||
submit.tp_da = AddressField(addr_ie.decoded['call_number'], addr_ie.decoded['ton_npi']['type_of_number'],
|
|
||||||
addr_ie.decoded['ton_npi']['numbering_plan_id'])
|
|
||||||
logger.info(submit)
|
|
||||||
self.send_sms_via_smpp(submit)
|
|
||||||
|
|
||||||
def handle_OpenChannel(self, pcmd: ProactiveCommand):
|
|
||||||
"""Card requests opening a new channel via a UDP/TCP socket."""
|
|
||||||
# {'open_channel': [{'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'open_channel',
|
|
||||||
# 'command_qualifier': 3}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'uicc',
|
|
||||||
# 'dest_dev_id': 'terminal'}},
|
|
||||||
# {'bearer_description': {'bearer_type': 'default',
|
|
||||||
# 'bearer_parameters': ''}},
|
|
||||||
# {'buffer_size': 1024},
|
|
||||||
# {'uicc_transport_level': {'protocol_type': 'tcp_uicc_client_remote',
|
|
||||||
# 'port_number': 32768}},
|
|
||||||
# {'other_address': {'type_of_address': 'ipv4',
|
|
||||||
# 'address': '01020304'}}
|
|
||||||
# ]}
|
|
||||||
logger.info("OpenChannel")
|
|
||||||
logger.info(pcmd)
|
|
||||||
transp_lvl_ie = Proact._find_first_element_of_type(pcmd.children, UiccTransportLevel)
|
|
||||||
other_addr_ie = Proact._find_first_element_of_type(pcmd.children, OtherAddress)
|
|
||||||
bearer_desc_ie = Proact._find_first_element_of_type(pcmd.children, BearerDescription)
|
|
||||||
buffer_size_ie = Proact._find_first_element_of_type(pcmd.children, BufferSize)
|
|
||||||
if transp_lvl_ie.decoded['protocol_type'] != 'tcp_uicc_client_remote':
|
|
||||||
raise ValueError('Unsupported protocol_type')
|
|
||||||
if other_addr_ie.decoded.get('type_of_address', None) != 'ipv4':
|
|
||||||
raise ValueError('Unsupported type_of_address')
|
|
||||||
ipv4_bytes = h2b(other_addr_ie.decoded['address'])
|
|
||||||
ipv4_str = '%u.%u.%u.%u' % (ipv4_bytes[0], ipv4_bytes[1], ipv4_bytes[2], ipv4_bytes[3])
|
|
||||||
port_nr = transp_lvl_ie.decoded['port_number']
|
|
||||||
print("%s:%u" % (ipv4_str, port_nr))
|
|
||||||
channel = self.channels.channel_create()
|
|
||||||
channel.ep = endpoints.TCP4ClientEndpoint(reactor, ipv4_str, port_nr)
|
|
||||||
channel.prot = TcpProtocol()
|
|
||||||
d = endpoints.connectProtocol(channel.ep, channel.prot)
|
|
||||||
# FIXME: why is this never called despite the client showing the inbound connection?
|
|
||||||
d.addCallback(tcp_connected_callback)
|
|
||||||
|
|
||||||
# Terminal Response example: [
|
|
||||||
# {'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'open_channel',
|
|
||||||
# 'command_qualifier': 3}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
|
||||||
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
|
|
||||||
# {'channel_status': '8100'},
|
|
||||||
# {'bearer_description': {'bearer_type': 'default', 'bearer_parameters': ''}},
|
|
||||||
# {'buffer_size': 1024}
|
|
||||||
# ]
|
|
||||||
return self.prepare_response(pcmd) + [ChannelStatus(decoded='8100'), bearer_desc_ie, buffer_size_ie]
|
|
||||||
|
|
||||||
def handle_CloseChannel(self, pcmd: ProactiveCommand):
|
|
||||||
"""Close a channel."""
|
|
||||||
logger.info("CloseChannel")
|
|
||||||
logger.info(pcmd)
|
|
||||||
|
|
||||||
def handle_ReceiveData(self, pcmd: ProactiveCommand):
|
|
||||||
"""Receive/read data from the socket."""
|
|
||||||
# {'receive_data': [{'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'receive_data',
|
|
||||||
# 'command_qualifier': 0}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'uicc',
|
|
||||||
# 'dest_dev_id': 'channel_1'}},
|
|
||||||
# {'channel_data_length': 9}
|
|
||||||
# ]}
|
|
||||||
logger.info("ReceiveData")
|
|
||||||
logger.info(pcmd)
|
|
||||||
# Terminal Response example: [
|
|
||||||
# {'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'receive_data',
|
|
||||||
# 'command_qualifier': 0}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
|
||||||
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
|
|
||||||
# {'channel_data': '16030100040e000000'},
|
|
||||||
# {'channel_data_length': 0}
|
|
||||||
# ]
|
|
||||||
return self.prepare_response(pcmd) + []
|
|
||||||
|
|
||||||
def handle_SendData(self, pcmd: ProactiveCommand):
|
|
||||||
"""Send/write data received from the SIM to the socket."""
|
|
||||||
# {'send_data': [{'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'send_data',
|
|
||||||
# 'command_qualifier': 1}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'uicc',
|
|
||||||
# 'dest_dev_id': 'channel_1'}},
|
|
||||||
# {'channel_data': '160301003c010000380303d0f45e12b52ce5bb522750dd037738195334c87a46a847fe2b6886cada9ea6bf00000a00ae008c008b00b0002c010000050001000101'}
|
|
||||||
# ]}
|
|
||||||
logger.info("SendData")
|
|
||||||
logger.info(pcmd)
|
|
||||||
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
|
|
||||||
chan_data_ie = Proact._find_first_element_of_type(pcmd.children, ChannelData)
|
|
||||||
chan_str = dev_id_ie.decoded['dest_dev_id']
|
|
||||||
chan_nr = 1 # FIXME
|
|
||||||
chan = self.channels.channels.get(chan_nr, None)
|
|
||||||
# FIXME chan.prot.transport.write(h2b(chan_data_ie.decoded))
|
|
||||||
# Terminal Response example: [
|
|
||||||
# {'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'send_data',
|
|
||||||
# 'command_qualifier': 1}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
|
||||||
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
|
|
||||||
# {'channel_data_length': 255}
|
|
||||||
# ]
|
|
||||||
return self.prepare_response(pcmd) + [ChannelDataLength(decoded=255)]
|
|
||||||
|
|
||||||
def handle_SetUpEventList(self, pcmd: ProactiveCommand):
|
|
||||||
# {'set_up_event_list': [{'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'set_up_event_list',
|
|
||||||
# 'command_qualifier': 0}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'uicc',
|
|
||||||
# 'dest_dev_id': 'terminal'}},
|
|
||||||
# {'event_list': ['data_available', 'channel_status']}
|
|
||||||
# ]}
|
|
||||||
logger.info("SetUpEventList")
|
|
||||||
logger.info(pcmd)
|
|
||||||
# Terminal Response example: [
|
|
||||||
# {'command_details': {'command_number': 1,
|
|
||||||
# 'type_of_command': 'set_up_event_list',
|
|
||||||
# 'command_qualifier': 0}},
|
|
||||||
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
|
||||||
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}}
|
|
||||||
# ]
|
|
||||||
return self.prepare_response(pcmd)
|
|
||||||
|
|
||||||
def getChannelStatus(self, pcmd: ProactiveCommand):
|
|
||||||
logger.info("GetChannelStatus")
|
|
||||||
logger.info(pcmd)
|
|
||||||
return self.prepare_response(pcmd) + []
|
|
||||||
|
|
||||||
def send_sms_via_smpp(self, submit: SMS_SUBMIT):
|
|
||||||
# while in a normal network the phone/ME would *submit* a message to the SMSC,
|
|
||||||
# we are actually emulating the SMSC itself, so we must *deliver* the message
|
|
||||||
# to the ESME
|
|
||||||
deliver = SMS_DELIVER.from_submit(submit)
|
|
||||||
deliver_smpp = deliver.to_smpp()
|
|
||||||
|
|
||||||
hackish_global_smpp.sendDataRequest(deliver_smpp)
|
|
||||||
# # obtain the connection/binding of system_id to be used for delivering MO-SMS to the ESME
|
|
||||||
# connection = smpp_server.getBoundConnections[system_id].getNextBindingForDelivery()
|
|
||||||
# connection.sendDataRequest(deliver_smpp)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def dcs_is_8bit(dcs):
|
def dcs_is_8bit(dcs):
|
||||||
if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT,
|
if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT,
|
||||||
pdu_types.DataCodingDefault.OCTET_UNSPECIFIED):
|
pdu_types.DataCodingDefault.OCTET_UNSPECIFIED):
|
||||||
@@ -323,6 +111,11 @@ class MyServer:
|
|||||||
smppEndpoint = endpoints.TCP6ServerEndpoint(reactor, tcp_port, interface=bind_ip)
|
smppEndpoint = endpoints.TCP6ServerEndpoint(reactor, tcp_port, interface=bind_ip)
|
||||||
smppEndpoint.listen(self.factory)
|
smppEndpoint.listen(self.factory)
|
||||||
self.tp = self.scc = self.card = None
|
self.tp = self.scc = self.card = None
|
||||||
|
# Serialise card/APDU access.
|
||||||
|
# - SMPP handler drives the card from reactor thread
|
||||||
|
# - BIP relay data-available path drives it from socket reader thread.
|
||||||
|
# The transport is not re-entrant, both must take this lock.
|
||||||
|
self._card_lock = threading.Lock()
|
||||||
|
|
||||||
def connect_to_card(self, tp: LinkBase):
|
def connect_to_card(self, tp: LinkBase):
|
||||||
self.tp = tp
|
self.tp = tp
|
||||||
@@ -333,8 +126,22 @@ class MyServer:
|
|||||||
self.scc.sel_ctrl = "0004"
|
self.scc.sel_ctrl = "0004"
|
||||||
self.card.read_aids()
|
self.card.read_aids()
|
||||||
self.card.select_adf_by_aid(adf='usim')
|
self.card.select_adf_by_aid(adf='usim')
|
||||||
# FIXME: create a more realistic profile than ffffff
|
self.scc.terminal_profile(b2h(terminal_profile()))
|
||||||
self.scc.terminal_profile('ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')
|
# Connect the BIP relay inbound path to the card.
|
||||||
|
# relay socket receives data -> ME initiated ENVELOPE EVENT DOWNLOA
|
||||||
|
# -> triggers RECEIVE DATA proactive session.
|
||||||
|
# FIXME this cross-thread push to the card is exercised only with real hardware
|
||||||
|
# the card free tests cover socket relay + envelope construction, not delivery.
|
||||||
|
handler = getattr(tp, 'proactive_handler', None)
|
||||||
|
if isinstance(handler, Proact):
|
||||||
|
handler.data_available_sink = self._deliver_data_available
|
||||||
|
|
||||||
|
def _deliver_data_available(self, envelope_hex: str):
|
||||||
|
"""push ME initiated ENVELOPE EVENT DOWNLOAD to the card"""
|
||||||
|
with self._card_lock:
|
||||||
|
logger.info("ENVELOPE(Data available): %s" % envelope_hex)
|
||||||
|
(data, sw) = self.scc.envelope(envelope_hex)
|
||||||
|
logger.info("SW %s: %s" % (sw, data))
|
||||||
|
|
||||||
def _msgHandler(self, system_id, smpp, pdu):
|
def _msgHandler(self, system_id, smpp, pdu):
|
||||||
"""Handler for incoming messages received via SMPP from ESME."""
|
"""Handler for incoming messages received via SMPP from ESME."""
|
||||||
@@ -362,14 +169,12 @@ class MyServer:
|
|||||||
tpdu = SMS_DELIVER.from_smpp_submit(pdu)
|
tpdu = SMS_DELIVER.from_smpp_submit(pdu)
|
||||||
logger.info(tpdu)
|
logger.info(tpdu)
|
||||||
# 2) wrap into the CAT ENVELOPE for SMS-PP-Download
|
# 2) wrap into the CAT ENVELOPE for SMS-PP-Download
|
||||||
tpdu_ie = SMS_TPDU(decoded={'tpdu': b2h(tpdu.to_bytes())})
|
sms_dl = sms_pp_download_envelope(tpdu)
|
||||||
addr_ie = Address(decoded={'ton_npi': {'ext':False, 'type_of_number':'unknown', 'numbering_plan_id':'unknown'}, 'call_number': '0123456'})
|
|
||||||
dev_ids = DeviceIdentities(decoded={'source_dev_id': 'network', 'dest_dev_id': 'uicc'})
|
|
||||||
sms_dl = SMSPPDownload(children=[dev_ids, addr_ie, tpdu_ie])
|
|
||||||
# 3) send to the card
|
# 3) send to the card
|
||||||
envelope_hex = b2h(sms_dl.to_tlv())
|
envelope_hex = b2h(sms_dl.to_tlv())
|
||||||
logger.info("ENVELOPE: %s" % envelope_hex)
|
logger.info("ENVELOPE: %s" % envelope_hex)
|
||||||
(data, sw) = self.scc.envelope(envelope_hex)
|
with self._card_lock:
|
||||||
|
(data, sw) = self.scc.envelope(envelope_hex)
|
||||||
logger.info("SW %s: %s" % (sw, data))
|
logger.info("SW %s: %s" % (sw, data))
|
||||||
if sw in ['9200', '9300']:
|
if sw in ['9200', '9300']:
|
||||||
# TODO send back RP-ERROR message with TP-FCS == 'SIM Application Toolkit Busy'
|
# TODO send back RP-ERROR message with TP-FCS == 'SIM Application Toolkit Busy'
|
||||||
@@ -416,7 +221,8 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
opts = option_parser.parse_args()
|
opts = option_parser.parse_args()
|
||||||
|
|
||||||
tp = init_reader(opts, proactive_handler = Proact())
|
tp = init_reader(opts, proactive_handler = Proact(
|
||||||
|
sms_sink=lambda pdu: hackish_global_smpp.sendDataRequest(pdu)))
|
||||||
if tp is None:
|
if tp is None:
|
||||||
exit(1)
|
exit(1)
|
||||||
tp.connect()
|
tp.connect()
|
||||||
|
|||||||
+8
-1
@@ -23,6 +23,7 @@ from pySim.apdu_source.gsmtap import GsmtapApduSource
|
|||||||
from pySim.apdu_source.pyshark_rspro import PysharkRsproPcap, PysharkRsproLive
|
from pySim.apdu_source.pyshark_rspro import PysharkRsproPcap, PysharkRsproLive
|
||||||
from pySim.apdu_source.pyshark_gsmtap import PysharkGsmtapPcap
|
from pySim.apdu_source.pyshark_gsmtap import PysharkGsmtapPcap
|
||||||
from pySim.apdu_source.tca_loader_log import TcaLoaderLogApduSource
|
from pySim.apdu_source.tca_loader_log import TcaLoaderLogApduSource
|
||||||
|
from pySim.apdu_source.stdin_hex import StdinHexApduSource
|
||||||
|
|
||||||
from pySim.apdu.ts_102_221 import UiccSelect, UiccStatus
|
from pySim.apdu.ts_102_221 import UiccSelect, UiccStatus
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ class Tracer:
|
|||||||
try:
|
try:
|
||||||
apdu = self.source.read()
|
apdu = self.source.read()
|
||||||
apdu_counter = apdu_counter + 1
|
apdu_counter = apdu_counter + 1
|
||||||
except StopIteration:
|
except (StopIteration, KeyboardInterrupt):
|
||||||
print("%i APDUs parsed, stop iteration." % apdu_counter)
|
print("%i APDUs parsed, stop iteration." % apdu_counter)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -190,6 +191,10 @@ parser_tcaloader_log = subparsers.add_parser('tca-loader-log', help="""
|
|||||||
parser_tcaloader_log.add_argument('-f', '--log-file', required=True,
|
parser_tcaloader_log.add_argument('-f', '--log-file', required=True,
|
||||||
help='Name of the log file to be read')
|
help='Name of the log file to be read')
|
||||||
|
|
||||||
|
parser_stdin_hex = subparsers.add_parser('stdin-hex', help="""
|
||||||
|
Read APDUs as hex-string from stdin.""")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
||||||
opts = option_parser.parse_args()
|
opts = option_parser.parse_args()
|
||||||
@@ -205,6 +210,8 @@ if __name__ == '__main__':
|
|||||||
s = PysharkGsmtapPcap(opts.pcap_file)
|
s = PysharkGsmtapPcap(opts.pcap_file)
|
||||||
elif opts.source == 'tca-loader-log':
|
elif opts.source == 'tca-loader-log':
|
||||||
s = TcaLoaderLogApduSource(opts.log_file)
|
s = TcaLoaderLogApduSource(opts.log_file)
|
||||||
|
elif opts.source == 'stdin-hex':
|
||||||
|
s = StdinHexApduSource()
|
||||||
else:
|
else:
|
||||||
raise ValueError("unsupported source %s", opts.source)
|
raise ValueError("unsupported source %s", opts.source)
|
||||||
|
|
||||||
|
|||||||
@@ -84,5 +84,5 @@ class PysharkGsmtapPcap(_PysharkGsmtap):
|
|||||||
Args:
|
Args:
|
||||||
pcap_filename: File name of the pcap file to be opened
|
pcap_filename: File name of the pcap file to be opened
|
||||||
"""
|
"""
|
||||||
pyshark_inst = pyshark.FileCapture(pcap_filename, display_filter='gsm_sim', use_json=True, keep_packets=False)
|
pyshark_inst = pyshark.FileCapture(pcap_filename, display_filter='gsm_sim || iso7816.atr', use_json=True, keep_packets=False)
|
||||||
super().__init__(pyshark_inst)
|
super().__init__(pyshark_inst)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
|
||||||
|
# (C) 2024 by Harald Welte <laforge@osmocom.org>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
from pySim.utils import h2b
|
||||||
|
|
||||||
|
from pySim.apdu.ts_102_221 import ApduCommands as UiccApduCommands
|
||||||
|
from pySim.apdu.ts_102_222 import ApduCommands as UiccAdmApduCommands
|
||||||
|
from pySim.apdu.ts_31_102 import ApduCommands as UsimApduCommands
|
||||||
|
from pySim.apdu.global_platform import ApduCommands as GpApduCommands
|
||||||
|
|
||||||
|
from . import ApduSource, PacketType, CardReset
|
||||||
|
|
||||||
|
ApduCommands = UiccApduCommands + UiccAdmApduCommands + UsimApduCommands + GpApduCommands
|
||||||
|
|
||||||
|
class StdinHexApduSource(ApduSource):
|
||||||
|
"""ApduSource for reading apdu hex-strings from stdin."""
|
||||||
|
|
||||||
|
def read_packet(self) -> PacketType:
|
||||||
|
while True:
|
||||||
|
command = input("C-APDU >")
|
||||||
|
if len(command) == 0:
|
||||||
|
continue
|
||||||
|
response = '9000'
|
||||||
|
return ApduCommands.parse_cmd_bytes(h2b(command) + h2b(response))
|
||||||
+11
-7
@@ -26,11 +26,15 @@ from pySim.cdma_ruim import CardProfileRUIM
|
|||||||
from pySim.ts_102_221 import CardProfileUICC
|
from pySim.ts_102_221 import CardProfileUICC
|
||||||
from pySim.utils import all_subclasses
|
from pySim.utils import all_subclasses
|
||||||
from pySim.exceptions import SwMatchError
|
from pySim.exceptions import SwMatchError
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
# we need to import this module so that the SysmocomSJA2 sub-class of
|
log = PySimLogger.get(__name__)
|
||||||
# CardModel is created, which will add the ATR-based matching and
|
|
||||||
# calling of SysmocomSJA2.add_files. See CardModel.apply_matching_models
|
# we need to import these modules so that the SysmocomSJA2 / SysmocomSJS1
|
||||||
|
# sub-classes of CardModel are created, which will add the ATR-based matching
|
||||||
|
# and calling of their add_files. See CardModel.apply_matching_models
|
||||||
import pySim.sysmocom_sja2
|
import pySim.sysmocom_sja2
|
||||||
|
import pySim.sysmocom_sjs1
|
||||||
|
|
||||||
# we need to import these modules so that the various sub-classes of
|
# we need to import these modules so that the various sub-classes of
|
||||||
# CardProfile are created, which will be used in init_card() to iterate
|
# CardProfile are created, which will be used in init_card() to iterate
|
||||||
@@ -54,7 +58,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
|
|||||||
|
|
||||||
# Wait up to three seconds for a card in reader and try to detect
|
# Wait up to three seconds for a card in reader and try to detect
|
||||||
# the card type.
|
# the card type.
|
||||||
print("Waiting for card...")
|
log.info("Waiting for card...")
|
||||||
sl.wait_for_card(3)
|
sl.wait_for_card(3)
|
||||||
|
|
||||||
# The user may opt to skip all card initialization. In this case only the
|
# The user may opt to skip all card initialization. In this case only the
|
||||||
@@ -66,7 +70,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
|
|||||||
generic_card = False
|
generic_card = False
|
||||||
card = card_detect(scc)
|
card = card_detect(scc)
|
||||||
if card is None:
|
if card is None:
|
||||||
print("Warning: Could not detect card type - assuming a generic card type...")
|
log.warning("Could not detect card type - assuming a generic card type...")
|
||||||
card = SimCardBase(scc)
|
card = SimCardBase(scc)
|
||||||
generic_card = True
|
generic_card = True
|
||||||
|
|
||||||
@@ -76,7 +80,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
|
|||||||
# just means that pySim was unable to recognize the card profile. This
|
# just means that pySim was unable to recognize the card profile. This
|
||||||
# may happen in particular with unprovisioned cards that do not have
|
# may happen in particular with unprovisioned cards that do not have
|
||||||
# any files on them yet.
|
# any files on them yet.
|
||||||
print("Unsupported card type!")
|
log.warning("Unsupported card type!")
|
||||||
return None, card
|
return None, card
|
||||||
|
|
||||||
# ETSI TS 102 221, Table 9.3 specifies a default for the PIN key
|
# ETSI TS 102 221, Table 9.3 specifies a default for the PIN key
|
||||||
@@ -87,7 +91,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
|
|||||||
if generic_card and isinstance(profile, CardProfileUICC):
|
if generic_card and isinstance(profile, CardProfileUICC):
|
||||||
card._adm_chv_num = 0x0A
|
card._adm_chv_num = 0x0A
|
||||||
|
|
||||||
print("Info: Card is of type: %s" % str(profile))
|
log.info("Card is of type: %s", str(profile))
|
||||||
|
|
||||||
# FIXME: this shouldn't really be here but somewhere else/more generic.
|
# FIXME: this shouldn't really be here but somewhere else/more generic.
|
||||||
# We cannot do it within pySim/profile.py as that would create circular
|
# We cannot do it within pySim/profile.py as that would create circular
|
||||||
|
|||||||
+58
-47
@@ -72,10 +72,10 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
|
|||||||
if do[0] == 0x01:
|
if do[0] == 0x01:
|
||||||
self.decoded = {'generic_access_rule': 'always'}
|
self.decoded = {'generic_access_rule': 'always'}
|
||||||
return self.decoded
|
return self.decoded
|
||||||
return ValueError('Invalid 1-byte generic APDU access rule')
|
raise ValueError('Invalid 1-byte generic APDU access rule')
|
||||||
else:
|
else:
|
||||||
if len(do) % 8:
|
if len(do) % 8:
|
||||||
return ValueError('Invalid non-modulo-8 length of APDU filter: %d' % len(do))
|
raise ValueError('Invalid non-modulo-8 length of APDU filter: %d' % len(do))
|
||||||
self.decoded = {'apdu_filter': []}
|
self.decoded = {'apdu_filter': []}
|
||||||
offset = 0
|
offset = 0
|
||||||
while offset < len(do):
|
while offset < len(do):
|
||||||
@@ -90,19 +90,19 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
|
|||||||
return b'\x00'
|
return b'\x00'
|
||||||
if self.decoded['generic_access_rule'] == 'always':
|
if self.decoded['generic_access_rule'] == 'always':
|
||||||
return b'\x01'
|
return b'\x01'
|
||||||
return ValueError('Invalid 1-byte generic APDU access rule')
|
raise ValueError('Invalid 1-byte generic APDU access rule')
|
||||||
else:
|
else:
|
||||||
if not 'apdu_filter' in self.decoded:
|
if not 'apdu_filter' in self.decoded:
|
||||||
return ValueError('Invalid APDU AR DO')
|
raise ValueError('Invalid APDU AR DO')
|
||||||
filters = self.decoded['apdu_filter']
|
filters = self.decoded['apdu_filter']
|
||||||
res = b''
|
res = b''
|
||||||
for f in filters:
|
for f in filters:
|
||||||
if not 'header' in f or not 'mask' in f:
|
if not 'header' in f or not 'mask' in f:
|
||||||
return ValueError('APDU filter must contain header and mask')
|
raise ValueError('APDU filter must contain header and mask')
|
||||||
header_b = h2b(f['header'])
|
header_b = h2b(f['header'])
|
||||||
mask_b = h2b(f['mask'])
|
mask_b = h2b(f['mask'])
|
||||||
if len(header_b) != 4 or len(mask_b) != 4:
|
if len(header_b) != 4 or len(mask_b) != 4:
|
||||||
return ValueError('APDU filter header and mask must each be 4 bytes')
|
raise ValueError('APDU filter header and mask must each be 4 bytes')
|
||||||
res += header_b + mask_b
|
res += header_b + mask_b
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ class ADF_ARAM(CardADF):
|
|||||||
cmd_do_enc = cmd_do.to_ie()
|
cmd_do_enc = cmd_do.to_ie()
|
||||||
cmd_do_len = len(cmd_do_enc)
|
cmd_do_len = len(cmd_do_enc)
|
||||||
if cmd_do_len > 255:
|
if cmd_do_len > 255:
|
||||||
return ValueError('DO > 255 bytes not supported yet')
|
raise ValueError('DO > 255 bytes not supported yet')
|
||||||
else:
|
else:
|
||||||
cmd_do_enc = b''
|
cmd_do_enc = b''
|
||||||
cmd_do_len = 0
|
cmd_do_len = 0
|
||||||
@@ -300,6 +300,51 @@ class ADF_ARAM(CardADF):
|
|||||||
'major': v_major, 'minor': v_minor, 'patch': v_patch}}])
|
'major': v_major, 'minor': v_minor, 'patch': v_patch}}])
|
||||||
return ADF_ARAM.xceive_apdu_tlv(scc, '80cadf21', cmd_do, ResponseAramConfigDO)
|
return ADF_ARAM.xceive_apdu_tlv(scc, '80cadf21', cmd_do, ResponseAramConfigDO)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def store_ref_ar_do(scc, aid:Hexstr, aid_empty:bool, device_app_id:Hexstr, pkg_ref:str,
|
||||||
|
apdu_filter:Hexstr, apdu_never:bool, apdu_always:bool,
|
||||||
|
nfc_always:bool, nfc_never:bool, android_permissions:Hexstr):
|
||||||
|
# REF
|
||||||
|
ref_do_content = []
|
||||||
|
if aid is not None:
|
||||||
|
ref_do_content += [{'aid_ref_do': aid}]
|
||||||
|
elif aid_empty:
|
||||||
|
ref_do_content += [{'aid_ref_empty_do': None}]
|
||||||
|
ref_do_content += [{'dev_app_id_ref_do': device_app_id}]
|
||||||
|
if pkg_ref:
|
||||||
|
ref_do_content += [{'pkg_ref_do': {'package_name_string': pkg_ref}}]
|
||||||
|
# AR
|
||||||
|
ar_do_content = []
|
||||||
|
if apdu_never:
|
||||||
|
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'never'}}]
|
||||||
|
elif apdu_always:
|
||||||
|
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'always'}}]
|
||||||
|
elif apdu_filter:
|
||||||
|
if len(apdu_filter) % 16:
|
||||||
|
raise ValueError(f'Invalid non-modulo-16 length of APDU filter: {len(apdu_filter)}')
|
||||||
|
offset = 0
|
||||||
|
apdu_filter_list = []
|
||||||
|
while offset < len(apdu_filter):
|
||||||
|
apdu_filter_list += [{'header': apdu_filter[offset:offset+8],
|
||||||
|
'mask': apdu_filter[offset+8:offset+16]}]
|
||||||
|
offset += 16 # Move offset to the beginning of the next apdu_filter object
|
||||||
|
ar_do_content += [{'apdu_ar_do': {'apdu_filter': apdu_filter_list}}]
|
||||||
|
if nfc_never:
|
||||||
|
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'never'}}]
|
||||||
|
elif nfc_always:
|
||||||
|
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'always'}}]
|
||||||
|
if android_permissions:
|
||||||
|
ar_do_content += [{'perm_ar_do': {'permissions': android_permissions}}]
|
||||||
|
d = [{'ref_ar_do': [{'ref_do': ref_do_content}, {'ar_do': ar_do_content}]}]
|
||||||
|
csrado = CommandStoreRefArDO()
|
||||||
|
csrado.from_val_dict(d)
|
||||||
|
return ADF_ARAM.store_data(scc, csrado)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def aram_delete_all(scc):
|
||||||
|
deldo = CommandDelete()
|
||||||
|
return ADF_ARAM.store_data(scc, deldo)
|
||||||
|
|
||||||
@with_default_category('Application-Specific Commands')
|
@with_default_category('Application-Specific Commands')
|
||||||
class AddlShellCommands(CommandSet):
|
class AddlShellCommands(CommandSet):
|
||||||
def do_aram_get_all(self, _opts):
|
def do_aram_get_all(self, _opts):
|
||||||
@@ -334,58 +379,25 @@ class ADF_ARAM(CardADF):
|
|||||||
apdu_grp.add_argument(
|
apdu_grp.add_argument(
|
||||||
'--apdu-filter', help='APDU filter: multiple groups of 8 hex bytes (4 byte CLA/INS/P1/P2 followed by 4 byte mask)')
|
'--apdu-filter', help='APDU filter: multiple groups of 8 hex bytes (4 byte CLA/INS/P1/P2 followed by 4 byte mask)')
|
||||||
nfc_grp = store_ref_ar_do_parse.add_mutually_exclusive_group()
|
nfc_grp = store_ref_ar_do_parse.add_mutually_exclusive_group()
|
||||||
nfc_grp.add_argument('--nfc-always', action='store_true',
|
|
||||||
help='NFC event access is allowed')
|
|
||||||
nfc_grp.add_argument('--nfc-never', action='store_true',
|
nfc_grp.add_argument('--nfc-never', action='store_true',
|
||||||
help='NFC event access is not allowed')
|
help='NFC event access is not allowed')
|
||||||
|
nfc_grp.add_argument('--nfc-always', action='store_true',
|
||||||
|
help='NFC event access is allowed')
|
||||||
store_ref_ar_do_parse.add_argument(
|
store_ref_ar_do_parse.add_argument(
|
||||||
'--android-permissions', help='Android UICC Carrier Privilege Permissions (8 hex bytes)')
|
'--android-permissions', help='Android UICC Carrier Privilege Permissions (8 hex bytes)')
|
||||||
|
|
||||||
@cmd2.with_argparser(store_ref_ar_do_parse)
|
@cmd2.with_argparser(store_ref_ar_do_parse)
|
||||||
def do_aram_store_ref_ar_do(self, opts):
|
def do_aram_store_ref_ar_do(self, opts):
|
||||||
"""Perform STORE DATA [Command-Store-REF-AR-DO] to store a (new) access rule."""
|
"""Perform STORE DATA [Command-Store-REF-AR-DO] to store a (new) access rule."""
|
||||||
# REF
|
res_do = ADF_ARAM.store_ref_ar_do(self._cmd.lchan.scc, opts.aid, opts.aid_empty, opts.device_app_id,
|
||||||
ref_do_content = []
|
opts.pkg_ref, opts.apdu_filter, opts.apdu_never, opts.apdu_always,
|
||||||
if opts.aid is not None:
|
opts.nfc_always, opts.nfc_never, opts.android_permissions)
|
||||||
ref_do_content += [{'aid_ref_do': opts.aid}]
|
|
||||||
elif opts.aid_empty:
|
|
||||||
ref_do_content += [{'aid_ref_empty_do': None}]
|
|
||||||
ref_do_content += [{'dev_app_id_ref_do': opts.device_app_id}]
|
|
||||||
if opts.pkg_ref:
|
|
||||||
ref_do_content += [{'pkg_ref_do': {'package_name_string': opts.pkg_ref}}]
|
|
||||||
# AR
|
|
||||||
ar_do_content = []
|
|
||||||
if opts.apdu_never:
|
|
||||||
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'never'}}]
|
|
||||||
elif opts.apdu_always:
|
|
||||||
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'always'}}]
|
|
||||||
elif opts.apdu_filter:
|
|
||||||
if len(opts.apdu_filter) % 16:
|
|
||||||
return ValueError('Invalid non-modulo-16 length of APDU filter: %d' % len(do))
|
|
||||||
offset = 0
|
|
||||||
apdu_filter = []
|
|
||||||
while offset < len(opts.apdu_filter):
|
|
||||||
apdu_filter += [{'header': opts.apdu_filter[offset:offset+8],
|
|
||||||
'mask': opts.apdu_filter[offset+8:offset+16]}]
|
|
||||||
offset += 16 # Move offset to the beginning of the next apdu_filter object
|
|
||||||
ar_do_content += [{'apdu_ar_do': {'apdu_filter': apdu_filter}}]
|
|
||||||
if opts.nfc_always:
|
|
||||||
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'always'}}]
|
|
||||||
elif opts.nfc_never:
|
|
||||||
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'never'}}]
|
|
||||||
if opts.android_permissions:
|
|
||||||
ar_do_content += [{'perm_ar_do': {'permissions': opts.android_permissions}}]
|
|
||||||
d = [{'ref_ar_do': [{'ref_do': ref_do_content}, {'ar_do': ar_do_content}]}]
|
|
||||||
csrado = CommandStoreRefArDO()
|
|
||||||
csrado.from_val_dict(d)
|
|
||||||
res_do = ADF_ARAM.store_data(self._cmd.lchan.scc, csrado)
|
|
||||||
if res_do:
|
if res_do:
|
||||||
self._cmd.poutput_json(res_do.to_dict())
|
self._cmd.poutput_json(res_do.to_dict())
|
||||||
|
|
||||||
def do_aram_delete_all(self, _opts):
|
def do_aram_delete_all(self, _opts):
|
||||||
"""Perform STORE DATA [Command-Delete[all]] to delete all access rules."""
|
"""Perform STORE DATA [Command-Delete[all]] to delete all access rules."""
|
||||||
deldo = CommandDelete()
|
res_do = ADF_ARAM.aram_delete_all(self._cmd.lchan.scc)
|
||||||
res_do = ADF_ARAM.store_data(self._cmd.lchan.scc, deldo)
|
|
||||||
if res_do:
|
if res_do:
|
||||||
self._cmd.poutput_json(res_do.to_dict())
|
self._cmd.poutput_json(res_do.to_dict())
|
||||||
|
|
||||||
@@ -394,7 +406,6 @@ class ADF_ARAM(CardADF):
|
|||||||
(Proprietary feature that is specific to sysmocom's fork of Bertrand Martel’s ARA-M implementation.)"""
|
(Proprietary feature that is specific to sysmocom's fork of Bertrand Martel’s ARA-M implementation.)"""
|
||||||
self._cmd.lchan.scc.send_apdu_checksw('80e2900001A1', '9000')
|
self._cmd.lchan.scc.send_apdu_checksw('80e2900001A1', '9000')
|
||||||
|
|
||||||
|
|
||||||
# SEAC v1.1 Section 4.1.2.2 + 5.1.2.2
|
# SEAC v1.1 Section 4.1.2.2 + 5.1.2.2
|
||||||
sw_aram = {
|
sw_aram = {
|
||||||
'ARA-M': {
|
'ARA-M': {
|
||||||
|
|||||||
+627
@@ -0,0 +1,627 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Bearer Independent Protocol relay"""
|
||||||
|
|
||||||
|
#
|
||||||
|
# (C) 2023-2024 by Harald Welte <laforge@osmocom.org>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# A ProactiveHandler with TCP sockets that backs the BIP channels,
|
||||||
|
# so a card can run its own IP session (SCP81/HTTPS, CAT_TP, ...)
|
||||||
|
#
|
||||||
|
# Currently used by pySim-smpp2sim.py which connects the SMS path to its SMPP server.
|
||||||
|
# Other drivers can pass their own sinks:
|
||||||
|
#
|
||||||
|
# handler = Proact(data_available_sink=..., sms_sink=...)
|
||||||
|
# tp = init_reader(opts, proactive_handler=handler)
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from osmocom.utils import b2h, h2b
|
||||||
|
|
||||||
|
from pySim.transport import ProactiveHandler
|
||||||
|
from pySim.sms import SMS_DELIVER, SMS_SUBMIT, AddressField
|
||||||
|
from pySim.cat import (ProactiveCommand, SendShortMessage, SMS_TPDU, SMSPPDownload,
|
||||||
|
BearerDescription, DeviceIdentities, Address, OtherAddress,
|
||||||
|
UiccTransportLevel, BufferSize, ChannelStatus, ChannelData,
|
||||||
|
ChannelDataLength, EventList, EventDownload, Result,
|
||||||
|
CommandDetails, LocationInformation)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# PROVIDE LOCAL INFORMATION location, GERAN TS 31.111 8.19.1
|
||||||
|
# - 3 byte PLMN of TS 24.008 10.5.1.3 -> 262-01
|
||||||
|
# - 2 byte LAC and a 2 byte cid.
|
||||||
|
DEFAULT_LOCATION = h2b('62f21000010001')
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_profile(num_channels: int = 7) -> bytes:
|
||||||
|
"""TERMINAL PROFILE for what we implement, TS 102 223 5.2 and annex T.
|
||||||
|
|
||||||
|
Annex T table T.1 lists what a Connected Entity, a CAT client that is not the modem
|
||||||
|
which is pretty much what we are, may announce, and its inverse is what only a modem may announce.
|
||||||
|
"""
|
||||||
|
if not 0 <= num_channels <= ProactChannels.MAX_CHANNELS:
|
||||||
|
raise ValueError('num_channels must be 0..%u' % ProactChannels.MAX_CHANNELS)
|
||||||
|
profile = bytearray(32)
|
||||||
|
# 1 (Download): b1 profile download, b2+b5 SMS-PP data download. Both of the latter, per the
|
||||||
|
# note in TS 31.111 5.2: "several bits may need to be set to 1 for the support of the same
|
||||||
|
# facility ... because of backward compatibility with SAT". The relay is OTA over SMS-PP.
|
||||||
|
profile[0] = 0x01 | 0x02 | 0x10
|
||||||
|
profile[1] = 0x01 # 2 (Other): b1 command result
|
||||||
|
profile[2] = 0x80 # 3: b8 REFRESH (empty result is a valid answer, 6.4.7)
|
||||||
|
profile[3] = 0x02 # 4: b2 SEND SHORT MESSAGE (the OTA response path)
|
||||||
|
profile[4] = 0x01 # 5: b1 SET UP EVENT LIST
|
||||||
|
profile[5] = 0x04 | 0x08 # 6: b3 Event Data available, b4 Event Channel status
|
||||||
|
# 12 (class "e"): b1..b5 OPEN CHANNEL, CLOSE CHANNEL, RECEIVE DATA, SEND DATA, GET CHANNEL
|
||||||
|
# STATUS.
|
||||||
|
profile[11] = 0x1f
|
||||||
|
# 13 (class "e" supported bearers): b2 GPRS, and b6..b8 the number of channels.
|
||||||
|
profile[12] = 0x02 | (num_channels << 5)
|
||||||
|
profile[13] = 0x40 | 0x20 # 14: b6 no display capability, b7 no keypad available
|
||||||
|
profile[16] = 0x01 # 15: b1 TCP, UICC in client mode, remote connection
|
||||||
|
return bytes(profile)
|
||||||
|
|
||||||
|
|
||||||
|
class ProactChannel:
|
||||||
|
"""One BIP channel, TS 102 223 class "e", backed by a blocking TCP socket.
|
||||||
|
|
||||||
|
Created by ProactChannels.channel_create(). A reader thread fills the Rx buffer from the
|
||||||
|
socket, the Proact handlers drain it (RECEIVE DATA) and write to it (SEND DATA). Payload
|
||||||
|
is opaque, TLS or CAT_TP run on the card.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channels: the owning ProactChannels, notified of data arrival and of close()
|
||||||
|
chan_nr: channel number 1..7 as used in the Device identities
|
||||||
|
"""
|
||||||
|
# Why blocking sockets and not Twisted endpoints, considering we have twisted?
|
||||||
|
# The proactive-command loop lives in a blocking while-loop,
|
||||||
|
# "pySim.transport.LinkBase.send_apdu_checksw" that runs on the Twisted reactor thread.
|
||||||
|
# A Twisted async TCP client only makes any progress when the reactor uhh... reacts, but
|
||||||
|
# the reactor is stuck in that loop for the whole proactive session -> the
|
||||||
|
# connectProtocol() Deferred never fires while we are handling OPEN/SEND/RECEIVE CHANNEL.
|
||||||
|
# Plain blocking sockets just work: connect() in handle_OpenChannel, send() in
|
||||||
|
# handle_SendData, recv() feeding a buffer for handle_ReceiveData. No need to make it
|
||||||
|
# harder than it has to be to handle the "massive" T0 bandwidth..
|
||||||
|
# how much we try to read off the socket per recv()
|
||||||
|
RECV_CHUNK = 4096
|
||||||
|
|
||||||
|
def __init__(self, channels: 'ProactChannels', chan_nr: int):
|
||||||
|
self.channels = channels
|
||||||
|
self.chan_nr = chan_nr
|
||||||
|
self.sock = None
|
||||||
|
# TS 102 223 says the terminal keeps an Rx buffer per channel; RECEIVE
|
||||||
|
# DATA drains it, and it is filled asynchronously as the peer sends.
|
||||||
|
self.rx_buf = bytearray()
|
||||||
|
self._rx_lock = threading.Lock()
|
||||||
|
self._reader = None
|
||||||
|
self._closing = False
|
||||||
|
self.peer_closed = False
|
||||||
|
|
||||||
|
def connect(self, host: str, port: int, timeout: float = 10.0):
|
||||||
|
"""Open the blocking TCP socket and start the background Rx reader."""
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
s.connect((host, port))
|
||||||
|
# Back to blocking mode for the reader thread.
|
||||||
|
# CLOSE CHANNEL unblocks the pending recv() via shutdown().
|
||||||
|
s.settimeout(None)
|
||||||
|
except OSError:
|
||||||
|
s.close()
|
||||||
|
raise
|
||||||
|
self.sock = s
|
||||||
|
self._reader = threading.Thread(target=self._rx_loop,
|
||||||
|
name='bip-rx-%d' % self.chan_nr, daemon=True)
|
||||||
|
self._reader.start()
|
||||||
|
|
||||||
|
def _rx_loop(self):
|
||||||
|
"""Continuously read from the socket into rx_buf, like a real ME.
|
||||||
|
|
||||||
|
TS 102 223 7.5.10.1 says the event is raised 'only if the targeted channel buffer is
|
||||||
|
empty when new data arrives in it', so the data available hook fires on the
|
||||||
|
empty->non-empty transition only. That is enough: every RECEIVE DATA response tells
|
||||||
|
the card how many bytes remain, so it keeps fetching until the buffer is empty, and
|
||||||
|
the next event restarts it when more data arrives."""
|
||||||
|
while not self._closing:
|
||||||
|
try:
|
||||||
|
data = self.sock.recv(self.RECV_CHUNK)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
break
|
||||||
|
if not data:
|
||||||
|
self.peer_closed = True
|
||||||
|
break
|
||||||
|
with self._rx_lock:
|
||||||
|
was_empty = len(self.rx_buf) == 0
|
||||||
|
self.rx_buf.extend(data)
|
||||||
|
if was_empty and not self._closing:
|
||||||
|
self.channels.notify_data_available(self)
|
||||||
|
|
||||||
|
def send(self, data: bytes):
|
||||||
|
"""Tx, write bytes to the socket == SEND DATA"""
|
||||||
|
self.sock.sendall(data)
|
||||||
|
|
||||||
|
def available_rx(self) -> int:
|
||||||
|
"""Number of bytes waiting in the Rx buffer, what RECEIVE DATA can return right now."""
|
||||||
|
with self._rx_lock:
|
||||||
|
return len(self.rx_buf)
|
||||||
|
|
||||||
|
def take_rx(self, n: int):
|
||||||
|
"""Take up to n bytes out of the Rx buffer. Returns (bytes, bytes still remaining)."""
|
||||||
|
with self._rx_lock:
|
||||||
|
chunk = bytes(self.rx_buf[:n])
|
||||||
|
del self.rx_buf[:n]
|
||||||
|
remaining = len(self.rx_buf)
|
||||||
|
return chunk, remaining
|
||||||
|
|
||||||
|
def wait_rx(self, timeout: float) -> int:
|
||||||
|
"""wait up to timeout seconds until the rxbuf has data
|
||||||
|
returns the number of bytes available
|
||||||
|
Cards have a "data available" event, card free callers use
|
||||||
|
this to wait for the echoed bytes."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
avail = self.available_rx()
|
||||||
|
if avail or self.peer_closed:
|
||||||
|
return avail
|
||||||
|
time.sleep(0.005)
|
||||||
|
return self.available_rx()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close channel: stop reader, close socket, drop bookkeeping."""
|
||||||
|
self._closing = True
|
||||||
|
if self.sock is not None:
|
||||||
|
try:
|
||||||
|
self.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# CLOSE CHANNEL synchronously handled inside the rx reader thread
|
||||||
|
# (data-available -> ENVELOPE -> FETCH -> handle_CloseChannel -> close),
|
||||||
|
# so close() can be called on the reader thread.
|
||||||
|
# Joining self raises "cannot join current thread" so better skip i..
|
||||||
|
# setting _closing + shutting down the socket already makes _rx_loop
|
||||||
|
# return on the next iteration anyway.
|
||||||
|
if self._reader is not None and self._reader is not threading.current_thread():
|
||||||
|
self._reader.join(timeout=1.0)
|
||||||
|
self.channels.channel_delete(self.chan_nr)
|
||||||
|
|
||||||
|
class ProactChannels:
|
||||||
|
"""The open BIP channels of one terminal, keyed by channel number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_data_available: callback(chan: ProactChannel), invoked from the channel's reader
|
||||||
|
thread when data arrives in an empty Rx buffer. Proact turns it into an
|
||||||
|
ENVELOPE EVENT DOWNLOAD (data available).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TS 102 223 8.56 channel identifier in 3 bits as "1 to 7", 0 == no channel available
|
||||||
|
# TERMINAL PROFILE has to agree with byte 13 , "number of channels supported by terminal"
|
||||||
|
MAX_CHANNELS = 7
|
||||||
|
|
||||||
|
def __init__(self, on_data_available=None):
|
||||||
|
self.channels = {}
|
||||||
|
self._on_data_available = on_data_available
|
||||||
|
|
||||||
|
def channel_create(self) -> ProactChannel:
|
||||||
|
"""Create a new proactive channel, allocating its integer number."""
|
||||||
|
for i in range(1, self.MAX_CHANNELS + 1):
|
||||||
|
if not i in self.channels:
|
||||||
|
self.channels[i] = ProactChannel(self, i)
|
||||||
|
return self.channels[i]
|
||||||
|
raise ValueError('Cannot allocate another channel: All channels active')
|
||||||
|
|
||||||
|
def channel_delete(self, chan_nr: int):
|
||||||
|
"""Forget a channel, called by ProactChannel.close()."""
|
||||||
|
self.channels.pop(chan_nr, None)
|
||||||
|
|
||||||
|
def notify_data_available(self, chan: ProactChannel):
|
||||||
|
"""Run the on_data_available callback for chan, if one was given."""
|
||||||
|
if self._on_data_available:
|
||||||
|
self._on_data_available(chan)
|
||||||
|
|
||||||
|
class Proact(ProactiveHandler):
|
||||||
|
"""ProactiveHandler that answers the BIP proactive commands with TCP sockets.
|
||||||
|
|
||||||
|
The transport calls the handle_* methods with the decoded proactive command and posts
|
||||||
|
the returned IE list as TERMINAL RESPONSE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_available_sink: callback(envelope_hex: str), called from a channel reader thread
|
||||||
|
with an encoded ENVELOPE EVENT DOWNLOAD (data available). The caller forwards it
|
||||||
|
to the card with the ENVELOPE command, the card then FETCHes RECEIVE DATA.
|
||||||
|
None: the event is only logged (card free / test mode).
|
||||||
|
sms_sink: callback(pdu), called with the SMPP deliver_sm of a SEND SHORT MESSAGE
|
||||||
|
the card issued; pySim-smpp2sim.py hands it to its SMPP server.
|
||||||
|
None: the SMS is logged and dropped.
|
||||||
|
location: Location information returned in PROVIDE LOCAL INFORMATION (location).
|
||||||
|
"""
|
||||||
|
def __init__(self, data_available_sink=None, sms_sink=None, location: bytes = DEFAULT_LOCATION):
|
||||||
|
self.data_available_sink = data_available_sink
|
||||||
|
self.sms_sink = sms_sink
|
||||||
|
self.location = location
|
||||||
|
self.channels = ProactChannels(on_data_available=self._on_channel_data_available)
|
||||||
|
|
||||||
|
def handle_ProvideLocalInformation(self, pcmd: ProactiveCommand):
|
||||||
|
"""only location
|
||||||
|
|
||||||
|
TS 102 223 6.8.7 says TERMINAL RESPONSE to PROVIDE LOCAL INFORMATION "shall"
|
||||||
|
contain the data object the command qualifier (6.6.15) asked for. At least answer '00',
|
||||||
|
location information, usually requested.
|
||||||
|
|
||||||
|
answering "terminal currently unable to process - no service", which is a handset
|
||||||
|
out of coverage makes SJA5 believe it and postpones the entire session!
|
||||||
|
it registers a location status event, starts a ten minute timer and waits for coverage."""
|
||||||
|
cmd_det_ie = Proact._find_first_element_of_type(pcmd.children, CommandDetails)
|
||||||
|
if cmd_det_ie is not None and cmd_det_ie.decoded['command_qualifier'] == 0x00:
|
||||||
|
return self.prepare_response(pcmd) + [LocationInformation(decoded=self.location)]
|
||||||
|
return self.prepare_response(pcmd)
|
||||||
|
|
||||||
|
def receive_fetch(self, pcmd: ProactiveCommand):
|
||||||
|
"""Answer anything this handler has no specific handler for.
|
||||||
|
|
||||||
|
A card coming up will usually issue PROVIDE LOCAL INFORMATION,
|
||||||
|
POLL INTERVAL or TIMER MANAGEMENT before it gets anywhere near a BIP channel,
|
||||||
|
whatever the TERMINAL PROFILE announces.
|
||||||
|
|
||||||
|
Note that this is not the spec-correct answer. TS 102 223 6.8.7
|
||||||
|
says a successful TERMINAL RESPONSE to PROVIDE LOCAL INFORMATION "shall" carry the
|
||||||
|
requested Local information data object, and 6.8.13/6.8.14 says the same for TIMER
|
||||||
|
MANAGEMENT, this returns empty results for all of them, which works with real cards.
|
||||||
|
|
||||||
|
Always "performed_successfully", never "command_beyond_terminal_capability" because
|
||||||
|
answering that to PROVIDE LOCAL INFORMATION makes a card refuse to open the session.
|
||||||
|
"""
|
||||||
|
logger.info("no handler for %s, answering performed_successfully",
|
||||||
|
type(pcmd.decoded).__name__)
|
||||||
|
return self.prepare_response(pcmd, 'performed_successfully')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_first_element_of_type(instlist, cls):
|
||||||
|
for i in instlist:
|
||||||
|
if isinstance(i, cls):
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel_nr_from_dev_ids(dev_id_ie: DeviceIdentities) -> int:
|
||||||
|
"""Maps id like channel_1 -> channel number.
|
||||||
|
TS 102 223 Section 8.7 says low nibble is channel number,
|
||||||
|
channel-N = 0x21..0x27"""
|
||||||
|
dest = dev_id_ie.decoded['dest_dev_id']
|
||||||
|
return DeviceIdentities.DEV_IDS.inverse[dest] & 0x0f
|
||||||
|
|
||||||
|
def _channel_for(self, dev_id_ie: DeviceIdentities):
|
||||||
|
"""Resolve the ProactChannel addressed by a command dev id, or None"""
|
||||||
|
return self.channels.channels.get(self._channel_nr_from_dev_ids(dev_id_ie), None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel_status(chan_nr: int, established: bool = True) -> str:
|
||||||
|
"""TS 102 223 Section 8.56 channel status value for the
|
||||||
|
default/network bearer:
|
||||||
|
- byte 3 low 3 bits = channel id
|
||||||
|
- bit 8 = link established
|
||||||
|
- byte 4 = 00 no further info"""
|
||||||
|
b3 = (0x80 if established else 0x00) | (chan_nr & 0x07)
|
||||||
|
return '%02x00' % b3
|
||||||
|
|
||||||
|
def _bip_response_head(self, pcmd: ProactiveCommand,
|
||||||
|
general_result: str = 'performed_successfully',
|
||||||
|
additional_information: str = ''):
|
||||||
|
"""CommandDetails / DeviceIdentities / Result head part of a BIP TERMINAL
|
||||||
|
RESPONSE. Built on prepare_response() but with two changes:
|
||||||
|
|
||||||
|
- Device identities forced source=terminal, dest=UICC.
|
||||||
|
TS 102 223 6.8.2 mandates for every TERMINAL RESPONSE
|
||||||
|
prepare_response() inverts the commands device id, which is
|
||||||
|
right for a uicc->terminal command but would yield a wrong
|
||||||
|
channel_N->UICC for the channel addressed BIP commands.
|
||||||
|
|
||||||
|
- Result is recreated for non success cases. prepare_response()
|
||||||
|
hard codes empty "additional information", but for enum results
|
||||||
|
like BIP error -> AddlInfoBip the empty value cannot be encoded at
|
||||||
|
all, so we always ask prepare_response() for a success Result
|
||||||
|
and swap for a properly encoded one here."""
|
||||||
|
head = self.prepare_response(pcmd, 'performed_successfully')
|
||||||
|
for i, ie in enumerate(head):
|
||||||
|
if isinstance(ie, DeviceIdentities):
|
||||||
|
head[i] = DeviceIdentities(decoded={'source_dev_id': 'terminal',
|
||||||
|
'dest_dev_id': 'uicc'})
|
||||||
|
elif isinstance(ie, Result) and general_result != 'performed_successfully':
|
||||||
|
res = Result()
|
||||||
|
res.from_dict({'result': {'general_result': general_result,
|
||||||
|
'additional_information': additional_information}})
|
||||||
|
head[i] = res
|
||||||
|
return head
|
||||||
|
|
||||||
|
def _build_data_available_envelope(self, chan: ProactChannel) -> bytes:
|
||||||
|
"""TS 102 223 7.5.10.2 ENVELOPE EVENT DOWNLOAD
|
||||||
|
Event list, Device id terminal->UICC, Channel status,
|
||||||
|
Channel data length (bytes available or FF for > 255)."""
|
||||||
|
avail = min(chan.available_rx(), 0xff)
|
||||||
|
ed = EventDownload(children=[
|
||||||
|
EventList(decoded=['data_available']),
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}),
|
||||||
|
ChannelStatus(decoded=self._channel_status(chan.chan_nr)),
|
||||||
|
ChannelDataLength(decoded=avail),
|
||||||
|
])
|
||||||
|
return ed.to_tlv()
|
||||||
|
|
||||||
|
def _on_channel_data_available(self, chan: ProactChannel):
|
||||||
|
"""rx reader thread hook: socket data arrived while the channel buffer
|
||||||
|
was empty. card uses ENVELOPE EVENT DOWNLOAD + responds by FETCHing RECEIVE DATA
|
||||||
|
proactive command. Card free only builds and logs"""
|
||||||
|
envelope_hex = b2h(self._build_data_available_envelope(chan))
|
||||||
|
logger.info("channel %u: %u byte(s) available -> ENVELOPE(Data available) %s",
|
||||||
|
chan.chan_nr, chan.available_rx(), envelope_hex)
|
||||||
|
if self.data_available_sink:
|
||||||
|
self.data_available_sink(envelope_hex)
|
||||||
|
|
||||||
|
# handle_*: called by the transport with the decoded proactive command, the returned IE
|
||||||
|
# list becomes the TERMINAL RESPONSE.
|
||||||
|
def handle_SendShortMessage(self, pcmd: ProactiveCommand):
|
||||||
|
# {'smspp_download': [{'device_identities': {'source_dev_id': 'network',
|
||||||
|
# 'dest_dev_id': 'uicc'}},
|
||||||
|
# {'address': {'ton_npi': {'ext': True,
|
||||||
|
# 'type_of_number': 'international',
|
||||||
|
# 'numbering_plan_id': 'isdn_e164'},
|
||||||
|
# 'call_number': '79'}},
|
||||||
|
# {'sms_tpdu': {'tpdu': '40048111227ff6407070611535004d02700000481516011212000001fe4c0943aea42e45021c078ae06c66afc09303608874b72f58bacadb0dcf665c29349c799fbb522e61709c9baf1890015e8e8e196e36153106c8b92f95153774'}}
|
||||||
|
# ]}
|
||||||
|
"""SEND SHORT MESSAGE: hand the MO-SMS to sms_sink, answer with success so the card
|
||||||
|
continues with the next part of a multi part response."""
|
||||||
|
logger.info("SendShortMessage")
|
||||||
|
logger.info(pcmd)
|
||||||
|
# Relevant parts in pcmd: Address, SMS_TPDU
|
||||||
|
addr_ie = Proact._find_first_element_of_type(pcmd.children, Address)
|
||||||
|
sms_tpdu_ie = Proact._find_first_element_of_type(pcmd.children, SMS_TPDU)
|
||||||
|
raw_tpdu = sms_tpdu_ie.decoded['tpdu']
|
||||||
|
submit = SMS_SUBMIT.from_bytes(raw_tpdu)
|
||||||
|
submit.tp_da = AddressField(addr_ie.decoded['call_number'], addr_ie.decoded['ton_npi']['type_of_number'],
|
||||||
|
addr_ie.decoded['ton_npi']['numbering_plan_id'])
|
||||||
|
logger.info(submit)
|
||||||
|
self.send_sms_via_smpp(submit)
|
||||||
|
# Return a successful TERMINAL RESPONSE.
|
||||||
|
# This is important:
|
||||||
|
# - without it the transport cannot complete the proactive command
|
||||||
|
# - for a multi part OTA response, the card would never be asked to give us
|
||||||
|
# the remaining SMS chunks.
|
||||||
|
# 'pcmd' is a decoded SendShortMessage IE, which contains CommandDetails and
|
||||||
|
# DeviceIdentities that prepare_response() echoes/inverts.
|
||||||
|
return self.prepare_response(pcmd)
|
||||||
|
|
||||||
|
def handle_OpenChannel(self, pcmd: ProactiveCommand):
|
||||||
|
"""OPEN CHANNEL: connect a TCP socket to the given address and port, allocate a
|
||||||
|
channel number and report it in the Channel status of the response."""
|
||||||
|
# {'open_channel': [{'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'open_channel',
|
||||||
|
# 'command_qualifier': 3}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'uicc',
|
||||||
|
# 'dest_dev_id': 'terminal'}},
|
||||||
|
# {'bearer_description': {'bearer_type': 'default',
|
||||||
|
# 'bearer_parameters': ''}},
|
||||||
|
# {'buffer_size': 1024},
|
||||||
|
# {'uicc_transport_level': {'protocol_type': 'tcp_uicc_client_remote',
|
||||||
|
# 'port_number': 32768}},
|
||||||
|
# {'other_address': {'type_of_address': 'ipv4',
|
||||||
|
# 'address': '01020304'}}
|
||||||
|
# ]}
|
||||||
|
logger.info("OpenChannel")
|
||||||
|
logger.info(pcmd)
|
||||||
|
transp_lvl_ie = Proact._find_first_element_of_type(pcmd.children, UiccTransportLevel)
|
||||||
|
other_addr_ie = Proact._find_first_element_of_type(pcmd.children, OtherAddress)
|
||||||
|
bearer_desc_ie = Proact._find_first_element_of_type(pcmd.children, BearerDescription)
|
||||||
|
buffer_size_ie = Proact._find_first_element_of_type(pcmd.children, BufferSize)
|
||||||
|
|
||||||
|
def refuse(additional_information: str, chan_nr: int = 0):
|
||||||
|
"""TERMINAL RESPONSE refusing the OPEN CHANNEL
|
||||||
|
|
||||||
|
- always a BIP error, only the cause byte of TS 102 223 8.12.11 differs
|
||||||
|
- chan_nr 0 -> "no channel available" in the Channel status, 8.56
|
||||||
|
- 6.8.18, 6.8.20, 6.8.21 want chan status, Bearer desc and buf size
|
||||||
|
in a successful or unsuccessful response
|
||||||
|
"""
|
||||||
|
ies = [ChannelStatus(decoded=self._channel_status(chan_nr, established=False))]
|
||||||
|
ies += [ie for ie in (bearer_desc_ie, buffer_size_ie) if ie is not None]
|
||||||
|
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
|
||||||
|
additional_information) + ies
|
||||||
|
|
||||||
|
# UICC/terminal interface transport level is Optional, TS 102 223 6.6.27.x. Absent means
|
||||||
|
# the CAT application runs its own network and transport layer, which we do not do.
|
||||||
|
if transp_lvl_ie is None or transp_lvl_ie.decoded['protocol_type'] != 'tcp_uicc_client_remote':
|
||||||
|
logger.warning("OpenChannel: unsupported UICC/terminal interface transport level (%s) "
|
||||||
|
"-> refusing", transp_lvl_ie.decoded if transp_lvl_ie else '(absent)')
|
||||||
|
return refuse('requested_uicc_if_transp_level_not_available')
|
||||||
|
if other_addr_ie is None or other_addr_ie.decoded.get('type_of_address', None) != 'ipv4':
|
||||||
|
# No cause byte fits a wrong address family. '06' is about the transport level data
|
||||||
|
# object, and 8.12.11 leaves '14' ("IPv4 only allowed") reserved by 3GPP, so '00'.
|
||||||
|
logger.warning("OpenChannel: unsupported data destination address (%s) -> refusing",
|
||||||
|
other_addr_ie.decoded if other_addr_ie else '(absent)')
|
||||||
|
return refuse('no_specific_cause')
|
||||||
|
addr_bytes = h2b(other_addr_ie.decoded['address']) if isinstance(
|
||||||
|
other_addr_ie.decoded['address'], str) else other_addr_ie.decoded['address']
|
||||||
|
ipv4_str = '%u.%u.%u.%u' % (addr_bytes[0], addr_bytes[1], addr_bytes[2], addr_bytes[3])
|
||||||
|
port_nr = transp_lvl_ie.decoded['port_number']
|
||||||
|
logger.info("OpenChannel: connecting to %s:%u", ipv4_str, port_nr)
|
||||||
|
try:
|
||||||
|
channel = self.channels.channel_create()
|
||||||
|
except ValueError:
|
||||||
|
# TS 102 223 6.4.27.2 and 6.4.27.3: no channel left -> BIP error
|
||||||
|
logger.warning("OpenChannel: all %u channels are in use -> refusing",
|
||||||
|
len(self.channels.channels))
|
||||||
|
return refuse('no_channel_availabile')
|
||||||
|
# yes, blocking connect()
|
||||||
|
try:
|
||||||
|
channel.connect(ipv4_str, port_nr)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("OpenChannel: connect to %s:%u failed: %s", ipv4_str, port_nr, e)
|
||||||
|
self.channels.channel_delete(channel.chan_nr)
|
||||||
|
# TS 102 223 6.4.30 is the only clause naming a cause for a link that could not be
|
||||||
|
# established: BIP error, channel closed. 6.4.27.4 lists no error cases at all.
|
||||||
|
return refuse('channel_closed', channel.chan_nr)
|
||||||
|
|
||||||
|
# Terminal Response example: [
|
||||||
|
# {'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'open_channel',
|
||||||
|
# 'command_qualifier': 3}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
||||||
|
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
|
||||||
|
# {'channel_status': '8100'},
|
||||||
|
# {'bearer_description': {'bearer_type': 'default', 'bearer_parameters': ''}},
|
||||||
|
# {'buffer_size': 1024}
|
||||||
|
# ]
|
||||||
|
return self._bip_response_head(pcmd) + [
|
||||||
|
ChannelStatus(decoded=self._channel_status(channel.chan_nr)),
|
||||||
|
bearer_desc_ie, buffer_size_ie]
|
||||||
|
|
||||||
|
def handle_CloseChannel(self, pcmd: ProactiveCommand):
|
||||||
|
"""CLOSE CHANNEL: close the socket of the addressed channel and free its number."""
|
||||||
|
logger.info("CloseChannel")
|
||||||
|
logger.info(pcmd)
|
||||||
|
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
|
||||||
|
chan = self._channel_for(dev_id_ie)
|
||||||
|
if chan is None:
|
||||||
|
# channel closed / invalid
|
||||||
|
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
|
||||||
|
'channel_id_not_valid')
|
||||||
|
chan.close()
|
||||||
|
return self._bip_response_head(pcmd)
|
||||||
|
|
||||||
|
def handle_ReceiveData(self, pcmd: ProactiveCommand):
|
||||||
|
"""RECEIVE DATA: the card fetches up to Channel data length bytes from the Rx buffer
|
||||||
|
of the addressed channel, the response also carries how many bytes remain."""
|
||||||
|
# {'receive_data': [{'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'receive_data',
|
||||||
|
# 'command_qualifier': 0}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'uicc',
|
||||||
|
# 'dest_dev_id': 'channel_1'}},
|
||||||
|
# {'channel_data_length': 9}
|
||||||
|
# ]}
|
||||||
|
logger.info("ReceiveData")
|
||||||
|
logger.info(pcmd)
|
||||||
|
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
|
||||||
|
req_len_ie = Proact._find_first_element_of_type(pcmd.children, ChannelDataLength)
|
||||||
|
chan = self._channel_for(dev_id_ie)
|
||||||
|
if chan is None:
|
||||||
|
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
|
||||||
|
'channel_id_not_valid')
|
||||||
|
# TS 102 223 8.54: RECEIVE DATA contains the requested count the card wants
|
||||||
|
requested = req_len_ie.decoded if req_len_ie is not None else chan.available_rx()
|
||||||
|
data, remaining = chan.take_rx(requested)
|
||||||
|
# TS 102 223 6.4.29:
|
||||||
|
# - return data available in the Rx buffer + num bytes still remaining (FF if > 255)
|
||||||
|
# - if fewer than requested available terminal must NOT wait, report and returns what we have
|
||||||
|
general_result = 'performed_successfully'
|
||||||
|
if len(data) < requested:
|
||||||
|
general_result = 'performed_with_missing_information'
|
||||||
|
# Terminal Response example: [
|
||||||
|
# {'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'receive_data',
|
||||||
|
# 'command_qualifier': 0}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
||||||
|
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
|
||||||
|
# {'channel_data': '16030100040e000000'},
|
||||||
|
# {'channel_data_length': 0}
|
||||||
|
# ]
|
||||||
|
return self._bip_response_head(pcmd, general_result) + [
|
||||||
|
ChannelData(decoded=b2h(data)),
|
||||||
|
ChannelDataLength(decoded=min(remaining, 0xff))]
|
||||||
|
|
||||||
|
def handle_SendData(self, pcmd: ProactiveCommand):
|
||||||
|
"""SEND DATA: write the Channel data of the command to the socket of the addressed
|
||||||
|
channel."""
|
||||||
|
# {'send_data': [{'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'send_data',
|
||||||
|
# 'command_qualifier': 1}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'uicc',
|
||||||
|
# 'dest_dev_id': 'channel_1'}},
|
||||||
|
# {'channel_data': '160301003c010000380303d0f45e12b52ce5bb522750dd037738195334c87a46a847fe2b6886cada9ea6bf00000a00ae008c008b00b0002c010000050001000101'}
|
||||||
|
# ]}
|
||||||
|
logger.info("SendData")
|
||||||
|
logger.info(pcmd)
|
||||||
|
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
|
||||||
|
chan_data_ie = Proact._find_first_element_of_type(pcmd.children, ChannelData)
|
||||||
|
chan = self._channel_for(dev_id_ie)
|
||||||
|
if chan is None:
|
||||||
|
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
|
||||||
|
'channel_id_not_valid')
|
||||||
|
# lets accept hexstrings as well
|
||||||
|
payload = chan_data_ie.decoded
|
||||||
|
if isinstance(payload, str):
|
||||||
|
payload = h2b(payload)
|
||||||
|
# command_qualifier bit 1 selects 'send immediately' / Tx-buffer store and forward
|
||||||
|
# For TCP stream all we have is a socket and TCP takes care of segmentation,
|
||||||
|
# so just send.
|
||||||
|
chan.send(payload)
|
||||||
|
# Terminal Response example: [
|
||||||
|
# {'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'send_data',
|
||||||
|
# 'command_qualifier': 1}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
||||||
|
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
|
||||||
|
# {'channel_data_length': 255}
|
||||||
|
# ]
|
||||||
|
# TS 102 223 6.4.30 / 8.54 Channel data length = free space tx buf; FF == > 255 available
|
||||||
|
return self._bip_response_head(pcmd) + [ChannelDataLength(decoded=255)]
|
||||||
|
|
||||||
|
def handle_SetUpEventList(self, pcmd: ProactiveCommand):
|
||||||
|
"""SET UP EVENT LIST: acknowledged, data available and channel status are always on."""
|
||||||
|
# {'set_up_event_list': [{'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'set_up_event_list',
|
||||||
|
# 'command_qualifier': 0}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'uicc',
|
||||||
|
# 'dest_dev_id': 'terminal'}},
|
||||||
|
# {'event_list': ['data_available', 'channel_status']}
|
||||||
|
# ]}
|
||||||
|
logger.info("SetUpEventList")
|
||||||
|
logger.info(pcmd)
|
||||||
|
# Terminal Response example: [
|
||||||
|
# {'command_details': {'command_number': 1,
|
||||||
|
# 'type_of_command': 'set_up_event_list',
|
||||||
|
# 'command_qualifier': 0}},
|
||||||
|
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
|
||||||
|
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}}
|
||||||
|
# ]
|
||||||
|
return self.prepare_response(pcmd)
|
||||||
|
|
||||||
|
def getChannelStatus(self, pcmd: ProactiveCommand):
|
||||||
|
logger.info("GetChannelStatus")
|
||||||
|
logger.info(pcmd)
|
||||||
|
return self.prepare_response(pcmd) + []
|
||||||
|
|
||||||
|
def send_sms_via_smpp(self, submit: SMS_SUBMIT):
|
||||||
|
# while in a normal network the phone/ME would *submit* a message to the SMSC,
|
||||||
|
# we are actually emulating the SMSC itself, so we must *deliver* the message
|
||||||
|
# to the ESME
|
||||||
|
deliver = SMS_DELIVER.from_submit(submit)
|
||||||
|
deliver_smpp = deliver.to_smpp()
|
||||||
|
|
||||||
|
if self.sms_sink is None:
|
||||||
|
logger.info('no sms_sink: dropping MO-SMS %s', deliver_smpp)
|
||||||
|
return
|
||||||
|
self.sms_sink(deliver_smpp)
|
||||||
|
# # obtain the connection/binding of system_id to be used for delivering MO-SMS to the ESME
|
||||||
|
# connection = smpp_server.getBoundConnections[system_id].getNextBindingForDelivery()
|
||||||
|
# connection.sendDataRequest(deliver_smpp)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ there are also automatic card feeders.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
#
|
#
|
||||||
# (C) 2019 by Sysmocom s.f.m.c. GmbH
|
# (C) 2019 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
|||||||
+134
-6
@@ -10,7 +10,7 @@ the need of manually entering the related card-individual data on every
|
|||||||
operation with pySim-shell.
|
operation with pySim-shell.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# (C) 2021-2025 by Sysmocom s.f.m.c. GmbH
|
# (C) 2021-2025 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# Author: Philipp Maier, Harald Welte
|
# Author: Philipp Maier, Harald Welte
|
||||||
@@ -33,11 +33,14 @@ from Cryptodome.Cipher import AES
|
|||||||
from osmocom.utils import h2b, b2h
|
from osmocom.utils import h2b, b2h
|
||||||
from pySim.log import PySimLogger
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
|
import os
|
||||||
import abc
|
import abc
|
||||||
import csv
|
import csv
|
||||||
import logging
|
import logging
|
||||||
|
import yaml
|
||||||
|
import argparse
|
||||||
|
|
||||||
log = PySimLogger.get("CARDKEY")
|
log = PySimLogger.get(__name__)
|
||||||
|
|
||||||
card_key_providers = [] # type: List['CardKeyProvider']
|
card_key_providers = [] # type: List['CardKeyProvider']
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ class CardKeyFieldCryptor:
|
|||||||
'UICC_SCP02': ['UICC_SCP02_KIC1', 'UICC_SCP02_KID1', 'UICC_SCP02_KIK1'],
|
'UICC_SCP02': ['UICC_SCP02_KIC1', 'UICC_SCP02_KID1', 'UICC_SCP02_KIK1'],
|
||||||
'UICC_SCP03': ['UICC_SCP03_KIC1', 'UICC_SCP03_KID1', 'UICC_SCP03_KIK1'],
|
'UICC_SCP03': ['UICC_SCP03_KIC1', 'UICC_SCP03_KID1', 'UICC_SCP03_KIK1'],
|
||||||
'SCP03_ISDR': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDR', 'SCP03_DEK_ISDR'],
|
'SCP03_ISDR': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDR', 'SCP03_DEK_ISDR'],
|
||||||
'SCP03_ISDA': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDA', 'SCP03_DEK_ISDA'],
|
'SCP03_ISDA': ['SCP03_ENC_ISDA', 'SCP03_MAC_ISDA', 'SCP03_DEK_ISDA'],
|
||||||
'SCP03_ECASD': ['SCP03_ENC_ECASD', 'SCP03_MAC_ECASD', 'SCP03_DEK_ECASD'],
|
'SCP03_ECASD': ['SCP03_ENC_ECASD', 'SCP03_MAC_ECASD', 'SCP03_DEK_ECASD'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,31 @@ class CardKeyFieldCryptor:
|
|||||||
cipher = AES.new(h2b(self.transport_keys[field_name.upper()]), AES.MODE_CBC, self.__IV)
|
cipher = AES.new(h2b(self.transport_keys[field_name.upper()]), AES.MODE_CBC, self.__IV)
|
||||||
return b2h(cipher.encrypt(h2b(plaintext_val)))
|
return b2h(cipher.encrypt(h2b(plaintext_val)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def argparse_add_args(arg_parser: argparse.ArgumentParser):
|
||||||
|
arg_parser.add_argument('--column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
|
||||||
|
help='per-column AES transport key', dest='column_key')
|
||||||
|
# Depprecated argument, replaced by --column-key (see above)
|
||||||
|
arg_parser.add_argument('--csv-column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
|
||||||
|
help=argparse.SUPPRESS, dest='column_key')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def transport_keys_from_opts(opts: argparse.Namespace) -> dict:
|
||||||
|
"""
|
||||||
|
Transport keys are passed via the commandline using the '--column-key' option. Each column requires a
|
||||||
|
dedicated transport key. This method can be used to extract the column keys parameters from the commandline
|
||||||
|
options into a dict that can be directly passed to the construtor with the transport_keys argument.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
opts: parsed commandline options (Namespace)
|
||||||
|
"""
|
||||||
|
|
||||||
|
transport_keys = {}
|
||||||
|
for par in opts.column_key:
|
||||||
|
name, key = par.split(':')
|
||||||
|
transport_keys[name] = key
|
||||||
|
return transport_keys
|
||||||
|
|
||||||
class CardKeyProvider(abc.ABC):
|
class CardKeyProvider(abc.ABC):
|
||||||
"""Base class, not containing any concrete implementation."""
|
"""Base class, not containing any concrete implementation."""
|
||||||
|
|
||||||
@@ -147,23 +175,33 @@ class CardKeyProvider(abc.ABC):
|
|||||||
fond None shall be returned.
|
fond None shall be returned.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def argparse_add_args(arg_parser: argparse.ArgumentParser):
|
||||||
|
"""
|
||||||
|
Add the commandline arguments relevant for this card key provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
arg_parser : argument parser group
|
||||||
|
"""
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return type(self).__name__
|
return type(self).__name__
|
||||||
|
|
||||||
class CardKeyProviderCsv(CardKeyProvider):
|
class CardKeyProviderCsv(CardKeyProvider):
|
||||||
"""Card key provider implementation that allows to query against a specified CSV file."""
|
"""Card key provider implementation that allows to query against a specified CSV file."""
|
||||||
|
|
||||||
def __init__(self, csv_filename: str, transport_keys: dict):
|
def __init__(self, csv_filename: str, field_cryptor: CardKeyFieldCryptor):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
csv_filename : file name (path) of CSV file containing card-individual key/data
|
csv_filename : file name (path) of CSV file containing card-individual key/data
|
||||||
transport_keys : (see class CardKeyFieldCryptor)
|
field_cryptor : (see class CardKeyFieldCryptor)
|
||||||
"""
|
"""
|
||||||
|
log.info("Using CSV file as card key data source: %s" % csv_filename)
|
||||||
self.csv_file = open(csv_filename, 'r')
|
self.csv_file = open(csv_filename, 'r')
|
||||||
if not self.csv_file:
|
if not self.csv_file:
|
||||||
raise RuntimeError("Could not open CSV file '%s'" % csv_filename)
|
raise RuntimeError("Could not open CSV file '%s'" % csv_filename)
|
||||||
self.csv_filename = csv_filename
|
self.csv_filename = csv_filename
|
||||||
self.crypt = CardKeyFieldCryptor(transport_keys)
|
self.crypt = field_cryptor
|
||||||
|
|
||||||
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
|
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
|
||||||
self.csv_file.seek(0)
|
self.csv_file.seek(0)
|
||||||
@@ -186,7 +224,81 @@ class CardKeyProviderCsv(CardKeyProvider):
|
|||||||
return None
|
return None
|
||||||
return return_dict
|
return return_dict
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def argparse_add_args(arg_parser: argparse.ArgumentParser):
|
||||||
|
arg_parser.add_argument('--csv', metavar='FILE',
|
||||||
|
default="~/.osmocom/pysim/card_data.csv",
|
||||||
|
help='Read card data from CSV file')
|
||||||
|
|
||||||
|
class CardKeyProviderPgsql(CardKeyProvider):
|
||||||
|
"""Card key provider implementation that allows to query against a specified PostgreSQL database table."""
|
||||||
|
|
||||||
|
def __init__(self, config_filename: str, field_cryptor: CardKeyFieldCryptor):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
config_filename : file name (path) of CSV file containing card-individual key/data
|
||||||
|
field_cryptor : (see class CardKeyFieldCryptor)
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
log.info("Using SQL database as card key data source: %s" % config_filename)
|
||||||
|
with open(config_filename, "r") as cfg:
|
||||||
|
config = yaml.load(cfg, Loader=yaml.FullLoader)
|
||||||
|
log.info("Card key database name: %s" % config.get('db_name'))
|
||||||
|
db_users = config.get('db_users')
|
||||||
|
user = db_users.get('reader')
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("user for role 'reader' not set up in config file.")
|
||||||
|
self.conn = psycopg2.connect(dbname=config.get('db_name'),
|
||||||
|
user=user.get('name'),
|
||||||
|
password=user.get('pass'),
|
||||||
|
host=config.get('host'))
|
||||||
|
self.tables = config.get('table_names')
|
||||||
|
log.info("Card key database tables: %s" % str(self.tables))
|
||||||
|
self.crypt = field_cryptor
|
||||||
|
|
||||||
|
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.sql import Identifier, SQL
|
||||||
|
db_result = None
|
||||||
|
for t in self.tables:
|
||||||
|
self.conn.rollback()
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
|
||||||
|
# Make sure that the database table and the key column actually exists. If not, move on to the next table
|
||||||
|
cur.execute("SELECT column_name FROM information_schema.columns where table_name = %s;", (t,))
|
||||||
|
cols_result = cur.fetchall()
|
||||||
|
if cols_result == []:
|
||||||
|
log.warning("Card Key database seems to lack table %s, check config file!" % t)
|
||||||
|
continue
|
||||||
|
if (key.lower(),) not in cols_result:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Query requested columns from database table
|
||||||
|
query = SQL("SELECT {}").format(Identifier(fields[0].lower()))
|
||||||
|
for f in fields[1:]:
|
||||||
|
query += SQL(", {}").format(Identifier(f.lower()))
|
||||||
|
query += SQL(" FROM {} WHERE {} = %s LIMIT 1;").format(Identifier(t.lower()),
|
||||||
|
Identifier(key.lower()))
|
||||||
|
cur.execute(query, (value,))
|
||||||
|
db_result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if db_result:
|
||||||
|
break
|
||||||
|
|
||||||
|
if db_result is None:
|
||||||
|
return None
|
||||||
|
result = dict(zip(fields, db_result))
|
||||||
|
|
||||||
|
for k in result.keys():
|
||||||
|
result[k] = self.crypt.decrypt_field(k, result.get(k))
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def argparse_add_args(arg_parser: argparse.ArgumentParser):
|
||||||
|
arg_parser.add_argument('--pgsql', metavar='FILE',
|
||||||
|
default="~/.osmocom/pysim/card_data_pgsql.cfg",
|
||||||
|
help='Read card data from PostgreSQL database (config file)')
|
||||||
|
|
||||||
def card_key_provider_register(provider: CardKeyProvider, provider_list=card_key_providers):
|
def card_key_provider_register(provider: CardKeyProvider, provider_list=card_key_providers):
|
||||||
"""Register a new card key provider.
|
"""Register a new card key provider.
|
||||||
@@ -240,3 +352,19 @@ def card_key_provider_get_field(field: str, key: str, value: str, provider_list=
|
|||||||
fields = [field]
|
fields = [field]
|
||||||
result = card_key_provider_get(fields, key, value, card_key_providers)
|
result = card_key_provider_get(fields, key, value, card_key_providers)
|
||||||
return result.get(field.upper())
|
return result.get(field.upper())
|
||||||
|
|
||||||
|
def card_key_provider_argparse_add_args(arg_parser: argparse.ArgumentParser):
|
||||||
|
"""Add card key provider commandline options to the given argument parser"""
|
||||||
|
card_key_group = arg_parser.add_argument_group('Card Key Provider Options')
|
||||||
|
CardKeyProviderCsv.argparse_add_args(card_key_group)
|
||||||
|
CardKeyProviderPgsql.argparse_add_args(card_key_group)
|
||||||
|
CardKeyFieldCryptor.argparse_add_args(card_key_group)
|
||||||
|
|
||||||
|
def card_key_provider_init(opts: argparse.Namespace):
|
||||||
|
"""Initialize card key provider depending on the user provided commandline options"""
|
||||||
|
transport_keys = CardKeyFieldCryptor.transport_keys_from_opts(opts)
|
||||||
|
card_key_field_cryptor = CardKeyFieldCryptor(transport_keys)
|
||||||
|
if os.path.isfile(os.path.expanduser(opts.csv)):
|
||||||
|
card_key_provider_register(CardKeyProviderCsv(os.path.expanduser(opts.csv), card_key_field_cryptor))
|
||||||
|
if os.path.isfile(os.path.expanduser(opts.pgsql)):
|
||||||
|
card_key_provider_register(CardKeyProviderPgsql(os.path.expanduser(opts.pgsql), card_key_field_cryptor))
|
||||||
|
|||||||
+83
-8
@@ -22,7 +22,7 @@ from typing import List
|
|||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
from construct import Int8ub, Int16ub, Byte, BitsInteger
|
from construct import Int8ub, Int16ub, Byte, BitsInteger
|
||||||
from construct import Struct, Enum, BitStruct, this
|
from construct import Struct, Enum, BitStruct, this
|
||||||
from construct import Switch, GreedyRange, FlagsEnum
|
from construct import Switch, GreedyRange, FlagsEnum, Adapter
|
||||||
from osmocom.tlv import TLV_IE, COMPR_TLV_IE, BER_TLV_IE, TLV_IE_Collection
|
from osmocom.tlv import TLV_IE, COMPR_TLV_IE, BER_TLV_IE, TLV_IE_Collection
|
||||||
from osmocom.construct import PlmnAdapter, BcdAdapter, GsmStringAdapter, TonNpi, GsmString, Bytes, GreedyBytes
|
from osmocom.construct import PlmnAdapter, BcdAdapter, GsmStringAdapter, TonNpi, GsmString, Bytes, GreedyBytes
|
||||||
from osmocom.utils import b2h, h2b
|
from osmocom.utils import b2h, h2b
|
||||||
@@ -318,11 +318,58 @@ class FileList(COMPR_TLV_IE, tag=0x92):
|
|||||||
|
|
||||||
# TS 102 223 Section 8.19
|
# TS 102 223 Section 8.19
|
||||||
class LocationInformation(COMPR_TLV_IE, tag=0x93):
|
class LocationInformation(COMPR_TLV_IE, tag=0x93):
|
||||||
pass
|
# 8.19: coding is per access technology, and the lengths differ (TS 131.111 8.19.1-.4: GERAN 7,
|
||||||
|
# UTRAN/E-UTRAN 9, NG-RAN 11) with nothing in the IE to say which -> keep the value opaque.
|
||||||
|
_construct = GreedyBytes
|
||||||
|
|
||||||
# TS 102 223 Section 8.20
|
class MobileIdentityAdapter(Adapter):
|
||||||
|
"""TS 124.008 section 10.5.1.4 figure 10.5.4 + table 10.5.4
|
||||||
|
|
||||||
|
NOT a plain BCD string:
|
||||||
|
- bits 1-3 type of identity + odd/even bit 4
|
||||||
|
- digit 1 in bits 5-8, following octets contain 2 digits, low nibble first
|
||||||
|
- if even length: high nibble of last octet 1111
|
||||||
|
So IMEI IE of 8 bytes is 15 digits + framing nibble."""
|
||||||
|
|
||||||
|
# Table 10.5.4 bits 321
|
||||||
|
TYPE_IMSI = 1
|
||||||
|
TYPE_IMEI = 2
|
||||||
|
TYPE_IMEISV = 3
|
||||||
|
|
||||||
|
def __init__(self, subcon, type_of_identity: int):
|
||||||
|
super().__init__(subcon)
|
||||||
|
self.type_of_identity = type_of_identity
|
||||||
|
|
||||||
|
def _decode(self, obj, context, path):
|
||||||
|
data = bytes(obj)
|
||||||
|
if not data:
|
||||||
|
return ''
|
||||||
|
# TS 24.008 figure 10.5.4: octet 3 holds type of identity (b1-3), odd/even (b4) and
|
||||||
|
# digit 1 in its high nibble, the remaining digits follow BCD swapped from octet 4
|
||||||
|
odd = bool(data[0] & 0x08) # bit 4: 1 = odd number of digits
|
||||||
|
digits = '%x' % (data[0] >> 4) # bits 5-8: digit 1
|
||||||
|
for octet in data[1:]:
|
||||||
|
digits += '%x%x' % (octet & 0x0f, octet >> 4)
|
||||||
|
if not odd:
|
||||||
|
digits = digits[:-1] # drop the 1111 end mark
|
||||||
|
return digits
|
||||||
|
|
||||||
|
def _encode(self, obj, context, path):
|
||||||
|
digits = str(obj)
|
||||||
|
odd = len(digits) % 2
|
||||||
|
first = (int(digits[0], 16) << 4) | (0x08 if odd else 0x00) | self.type_of_identity
|
||||||
|
rest = digits[1:] if odd else digits[1:] + 'f'
|
||||||
|
return bytes([first]) + bytes((int(rest[i+1], 16) << 4) | int(rest[i], 16)
|
||||||
|
for i in range(0, len(rest), 2))
|
||||||
|
|
||||||
|
# TS 102 223 Section 8.20, len is fixed at 8: "The IMEI is coded [..] as the
|
||||||
|
# value part of the Mobile Identity IE as specified in TS 124 008", and the
|
||||||
|
# IMEI itself is the 15 digits of TS 123 003.
|
||||||
class IMEI(COMPR_TLV_IE, tag=0x94):
|
class IMEI(COMPR_TLV_IE, tag=0x94):
|
||||||
_construct = BcdAdapter(GreedyBytes)
|
_test_de_encode = [
|
||||||
|
( '94081a32547698103254', '123456789012345' ),
|
||||||
|
]
|
||||||
|
_construct = MobileIdentityAdapter(GreedyBytes, MobileIdentityAdapter.TYPE_IMEI)
|
||||||
|
|
||||||
# TS 102 223 Section 8.21
|
# TS 102 223 Section 8.21
|
||||||
class HelpRequest(COMPR_TLV_IE, tag=0x95):
|
class HelpRequest(COMPR_TLV_IE, tag=0x95):
|
||||||
@@ -536,9 +583,9 @@ class Aid(COMPR_TLV_IE, tag=0xAF):
|
|||||||
|
|
||||||
# TS 102 223 Section 8.61
|
# TS 102 223 Section 8.61
|
||||||
class AccessTechnology(COMPR_TLV_IE, tag=0xBF):
|
class AccessTechnology(COMPR_TLV_IE, tag=0xBF):
|
||||||
SingleAccessTech = Enum(Int8ub, gsm=0, tia_eia_533=1, tia_eia_136_270=2, utran=3, tetra=4,
|
SingleAccessTech = Enum(Int8ub, gsm=0, tia_eia_553=1, tia_eia_136_270=2, utran=3, tetra=4,
|
||||||
tia_eia_95_b=5, cdma1000_1x=6, cdma2000_hrpd=7, eutran=8,
|
tia_eia_95_b=5, cdma2000_1x=6, cdma2000_hrpd=7, eutran=8,
|
||||||
ehrpd=9, nr=0x0a)
|
ehrpd=9, nr=0x0a, satellite_nr=0x0b, satellite_eutran=0x0c)
|
||||||
_construct = GreedyRange(SingleAccessTech)
|
_construct = GreedyRange(SingleAccessTech)
|
||||||
|
|
||||||
# TS 102 223 Section 8.63
|
# TS 102 223 Section 8.63
|
||||||
@@ -596,6 +643,14 @@ class UtranEutranMeasurementQualifier(COMPR_TLV_IE, tag=0xE9):
|
|||||||
eutran_inter_rat_utran=0x08,
|
eutran_inter_rat_utran=0x08,
|
||||||
eutran_inter_rat_nr=0x09)
|
eutran_inter_rat_nr=0x09)
|
||||||
|
|
||||||
|
# TS 102 223 Section 8.74, length is not fixed, because IMEISV is 16 digits per TS 123.003
|
||||||
|
# -> even count needs the '1111' end mark and is 9 bytes long
|
||||||
|
class IMEISV(COMPR_TLV_IE, tag=0xE2):
|
||||||
|
_test_de_encode = [
|
||||||
|
( 'e2091332547698103254f6', '1234567890123456' ),
|
||||||
|
]
|
||||||
|
_construct = MobileIdentityAdapter(GreedyBytes, MobileIdentityAdapter.TYPE_IMEISV)
|
||||||
|
|
||||||
# TS 102 223 Section 8.75
|
# TS 102 223 Section 8.75
|
||||||
class NetworkSearchMode(COMPR_TLV_IE, tag=0xE5):
|
class NetworkSearchMode(COMPR_TLV_IE, tag=0xE5):
|
||||||
_construct = Enum(Int8ub, manual=0, automatic=1)
|
_construct = Enum(Int8ub, manual=0, automatic=1)
|
||||||
@@ -729,8 +784,12 @@ class DnsServerAddress(COMPR_TLV_IE, tag=0xC0):
|
|||||||
|
|
||||||
# TS 102 223 Section 8.105
|
# TS 102 223 Section 8.105
|
||||||
class SupportedRadioAccessTechnologies(COMPR_TLV_IE, tag=0xB4):
|
class SupportedRadioAccessTechnologies(COMPR_TLV_IE, tag=0xB4):
|
||||||
|
# 2 bytes/entry:
|
||||||
|
# - technology of 8.61
|
||||||
|
# - state byte b1 is 0 disabled/1 enabled
|
||||||
|
# - b2-b8 RFU.
|
||||||
AccessTechTuple = Struct('technology'/AccessTechnology.SingleAccessTech,
|
AccessTechTuple = Struct('technology'/AccessTechnology.SingleAccessTech,
|
||||||
'state'/FlagsEnum(Int8ub, enabled=0))
|
'state'/FlagsEnum(Int8ub, enabled=1))
|
||||||
_construct = GreedyRange(AccessTechTuple)
|
_construct = GreedyRange(AccessTechTuple)
|
||||||
|
|
||||||
# TS 102 223 Section 8.107
|
# TS 102 223 Section 8.107
|
||||||
@@ -763,6 +822,22 @@ class SMSPPDownload(BER_TLV_IE, tag=0xD1,
|
|||||||
nested=[DeviceIdentities, Address, SMS_TPDU]):
|
nested=[DeviceIdentities, Address, SMS_TPDU]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sms_pp_download_envelope(tpdu, call_number: str = '0123456') -> SMSPPDownload:
|
||||||
|
"""TS 31.111 Section 7.1.1.2 wrap of a SMS-DELIVER TPDU in the ENVELOPE (SMS-PP Download)
|
||||||
|
call_number :
|
||||||
|
SMSC address to report, defined in TS 31.111 7.1.1.2 as
|
||||||
|
"the RP_Originating_Address of the Service Centre (TS-Service-Centre-Address, 3GPP TS 24.011)"
|
||||||
|
its presence is Conditional, and the note there says the UICC should be fine
|
||||||
|
if its missing, so for remote management its presence should suffice (?).
|
||||||
|
"""
|
||||||
|
return SMSPPDownload(children=[
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'network', 'dest_dev_id': 'uicc'}),
|
||||||
|
Address(decoded={'ton_npi': {'ext': False, 'type_of_number': 'unknown',
|
||||||
|
'numbering_plan_id': 'unknown'},
|
||||||
|
'call_number': call_number}),
|
||||||
|
SMS_TPDU(decoded={'tpdu': b2h(tpdu.to_bytes())})])
|
||||||
|
|
||||||
# TS 101 220 Table 7.17 + 31.111 7.1.1.3
|
# TS 101 220 Table 7.17 + 31.111 7.1.1.3
|
||||||
class SMSCBDownload(BER_TLV_IE, tag=0xD2,
|
class SMSCBDownload(BER_TLV_IE, tag=0xD2,
|
||||||
nested=[DeviceIdentities, CBSPage]):
|
nested=[DeviceIdentities, CBSPage]):
|
||||||
|
|||||||
+2
-2
@@ -128,10 +128,10 @@ class EF_AD(TransparentEF):
|
|||||||
cell_test = 0x04
|
cell_test = 0x04
|
||||||
|
|
||||||
def __init__(self, fid='6f43', sfid=None, name='EF.AD',
|
def __init__(self, fid='6f43', sfid=None, name='EF.AD',
|
||||||
desc='Service Provider Name', size=(3, None), **kwargs):
|
desc='Administrative Data', size=(3, None), **kwargs):
|
||||||
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
|
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
|
||||||
self._construct = Struct(
|
self._construct = Struct(
|
||||||
# Byte 1: Display Condition
|
# Byte 1: MS operation mode
|
||||||
'ms_operation_mode'/Enum(Byte, self.OP_MODE),
|
'ms_operation_mode'/Enum(Byte, self.OP_MODE),
|
||||||
# Bytes 2-3: Additional information
|
# Bytes 2-3: Additional information
|
||||||
'additional_info'/Bytes(2),
|
'additional_info'/Bytes(2),
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ def compile_asn1_subdir(subdir_name:str, codec='der'):
|
|||||||
__ver = sys.version_info
|
__ver = sys.version_info
|
||||||
if (__ver.major, __ver.minor) >= (3, 9):
|
if (__ver.major, __ver.minor) >= (3, 9):
|
||||||
for i in resources.files('pySim.esim').joinpath('asn1').joinpath(subdir_name).iterdir():
|
for i in resources.files('pySim.esim').joinpath('asn1').joinpath(subdir_name).iterdir():
|
||||||
|
if not i.name.endswith('.asn'):
|
||||||
|
continue
|
||||||
asn_txt += i.read_text()
|
asn_txt += i.read_text()
|
||||||
asn_txt += "\n"
|
asn_txt += "\n"
|
||||||
#else:
|
#else:
|
||||||
|
|||||||
+137
-14
@@ -16,6 +16,12 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from klein import Klein
|
||||||
|
from twisted.internet import defer, protocol, ssl, task, endpoints, reactor
|
||||||
|
from twisted.internet.posixbase import PosixReactorBase
|
||||||
|
from pathlib import Path
|
||||||
|
from twisted.web.server import Site, Request
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import time
|
import time
|
||||||
@@ -27,7 +33,7 @@ logger.setLevel(logging.DEBUG)
|
|||||||
|
|
||||||
class param:
|
class param:
|
||||||
class Iccid(ApiParamString):
|
class Iccid(ApiParamString):
|
||||||
"""String representation of 19 or 20 digits, where the 20th digit MAY optionally be the padding
|
"""String representation of 18 to 20 digits, where the 20th digit MAY optionally be the padding
|
||||||
character F."""
|
character F."""
|
||||||
@classmethod
|
@classmethod
|
||||||
def _encode(cls, data):
|
def _encode(cls, data):
|
||||||
@@ -40,7 +46,7 @@ class param:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def verify_encoded(cls, data):
|
def verify_encoded(cls, data):
|
||||||
if len(data) not in [19, 20]:
|
if len(data) not in (18, 19, 20):
|
||||||
raise ValueError('ICCID (%s) length (%u) invalid' % (data, len(data)))
|
raise ValueError('ICCID (%s) length (%u) invalid' % (data, len(data)))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -53,7 +59,7 @@ class param:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def verify_decoded(cls, data):
|
def verify_decoded(cls, data):
|
||||||
data = str(data)
|
data = str(data)
|
||||||
if len(data) not in [19, 20]:
|
if len(data) not in (18, 19, 20):
|
||||||
raise ValueError('ICCID (%s) length (%u) invalid' % (data, len(data)))
|
raise ValueError('ICCID (%s) length (%u) invalid' % (data, len(data)))
|
||||||
if len(data) == 19:
|
if len(data) == 19:
|
||||||
decimal_part = data
|
decimal_part = data
|
||||||
@@ -123,10 +129,12 @@ class Es2PlusApiFunction(JsonHttpApiFunction):
|
|||||||
class DownloadOrder(Es2PlusApiFunction):
|
class DownloadOrder(Es2PlusApiFunction):
|
||||||
path = '/gsma/rsp2/es2plus/downloadOrder'
|
path = '/gsma/rsp2/es2plus/downloadOrder'
|
||||||
input_params = {
|
input_params = {
|
||||||
|
'header': JsonRequestHeader,
|
||||||
'eid': param.Eid,
|
'eid': param.Eid,
|
||||||
'iccid': param.Iccid,
|
'iccid': param.Iccid,
|
||||||
'profileType': param.ProfileType
|
'profileType': param.ProfileType
|
||||||
}
|
}
|
||||||
|
input_mandatory = ['header']
|
||||||
output_params = {
|
output_params = {
|
||||||
'header': JsonResponseHeader,
|
'header': JsonResponseHeader,
|
||||||
'iccid': param.Iccid,
|
'iccid': param.Iccid,
|
||||||
@@ -137,6 +145,7 @@ class DownloadOrder(Es2PlusApiFunction):
|
|||||||
class ConfirmOrder(Es2PlusApiFunction):
|
class ConfirmOrder(Es2PlusApiFunction):
|
||||||
path = '/gsma/rsp2/es2plus/confirmOrder'
|
path = '/gsma/rsp2/es2plus/confirmOrder'
|
||||||
input_params = {
|
input_params = {
|
||||||
|
'header': JsonRequestHeader,
|
||||||
'iccid': param.Iccid,
|
'iccid': param.Iccid,
|
||||||
'eid': param.Eid,
|
'eid': param.Eid,
|
||||||
'matchingId': param.MatchingId,
|
'matchingId': param.MatchingId,
|
||||||
@@ -144,7 +153,7 @@ class ConfirmOrder(Es2PlusApiFunction):
|
|||||||
'smdsAddress': param.SmdsAddress,
|
'smdsAddress': param.SmdsAddress,
|
||||||
'releaseFlag': param.ReleaseFlag,
|
'releaseFlag': param.ReleaseFlag,
|
||||||
}
|
}
|
||||||
input_mandatory = ['iccid', 'releaseFlag']
|
input_mandatory = ['header', 'iccid', 'releaseFlag']
|
||||||
output_params = {
|
output_params = {
|
||||||
'header': JsonResponseHeader,
|
'header': JsonResponseHeader,
|
||||||
'eid': param.Eid,
|
'eid': param.Eid,
|
||||||
@@ -157,12 +166,13 @@ class ConfirmOrder(Es2PlusApiFunction):
|
|||||||
class CancelOrder(Es2PlusApiFunction):
|
class CancelOrder(Es2PlusApiFunction):
|
||||||
path = '/gsma/rsp2/es2plus/cancelOrder'
|
path = '/gsma/rsp2/es2plus/cancelOrder'
|
||||||
input_params = {
|
input_params = {
|
||||||
|
'header': JsonRequestHeader,
|
||||||
'iccid': param.Iccid,
|
'iccid': param.Iccid,
|
||||||
'eid': param.Eid,
|
'eid': param.Eid,
|
||||||
'matchingId': param.MatchingId,
|
'matchingId': param.MatchingId,
|
||||||
'finalProfileStatusIndicator': param.FinalProfileStatusIndicator,
|
'finalProfileStatusIndicator': param.FinalProfileStatusIndicator,
|
||||||
}
|
}
|
||||||
input_mandatory = ['finalProfileStatusIndicator', 'iccid']
|
input_mandatory = ['header', 'finalProfileStatusIndicator', 'iccid']
|
||||||
output_params = {
|
output_params = {
|
||||||
'header': JsonResponseHeader,
|
'header': JsonResponseHeader,
|
||||||
}
|
}
|
||||||
@@ -172,9 +182,10 @@ class CancelOrder(Es2PlusApiFunction):
|
|||||||
class ReleaseProfile(Es2PlusApiFunction):
|
class ReleaseProfile(Es2PlusApiFunction):
|
||||||
path = '/gsma/rsp2/es2plus/releaseProfile'
|
path = '/gsma/rsp2/es2plus/releaseProfile'
|
||||||
input_params = {
|
input_params = {
|
||||||
|
'header': JsonRequestHeader,
|
||||||
'iccid': param.Iccid,
|
'iccid': param.Iccid,
|
||||||
}
|
}
|
||||||
input_mandatory = ['iccid']
|
input_mandatory = ['header', 'iccid']
|
||||||
output_params = {
|
output_params = {
|
||||||
'header': JsonResponseHeader,
|
'header': JsonResponseHeader,
|
||||||
}
|
}
|
||||||
@@ -184,6 +195,7 @@ class ReleaseProfile(Es2PlusApiFunction):
|
|||||||
class HandleDownloadProgressInfo(Es2PlusApiFunction):
|
class HandleDownloadProgressInfo(Es2PlusApiFunction):
|
||||||
path = '/gsma/rsp2/es2plus/handleDownloadProgressInfo'
|
path = '/gsma/rsp2/es2plus/handleDownloadProgressInfo'
|
||||||
input_params = {
|
input_params = {
|
||||||
|
'header': JsonRequestHeader,
|
||||||
'eid': param.Eid,
|
'eid': param.Eid,
|
||||||
'iccid': param.Iccid,
|
'iccid': param.Iccid,
|
||||||
'profileType': param.ProfileType,
|
'profileType': param.ProfileType,
|
||||||
@@ -192,10 +204,9 @@ class HandleDownloadProgressInfo(Es2PlusApiFunction):
|
|||||||
'notificationPointStatus': param.NotificationPointStatus,
|
'notificationPointStatus': param.NotificationPointStatus,
|
||||||
'resultData': param.ResultData,
|
'resultData': param.ResultData,
|
||||||
}
|
}
|
||||||
input_mandatory = ['iccid', 'profileType', 'timestamp', 'notificationPointId', 'notificationPointStatus']
|
input_mandatory = ['header', 'iccid', 'profileType', 'timestamp', 'notificationPointId', 'notificationPointStatus']
|
||||||
expected_http_status = 204
|
expected_http_status = 204
|
||||||
|
|
||||||
|
|
||||||
class Es2pApiClient:
|
class Es2pApiClient:
|
||||||
"""Main class representing a full ES2+ API client. Has one method for each API function."""
|
"""Main class representing a full ES2+ API client. Has one method for each API function."""
|
||||||
def __init__(self, url_prefix:str, func_req_id:str, server_cert_verify: str = None, client_cert: str = None):
|
def __init__(self, url_prefix:str, func_req_id:str, server_cert_verify: str = None, client_cert: str = None):
|
||||||
@@ -206,18 +217,17 @@ class Es2pApiClient:
|
|||||||
if client_cert:
|
if client_cert:
|
||||||
self.session.cert = client_cert
|
self.session.cert = client_cert
|
||||||
|
|
||||||
self.downloadOrder = DownloadOrder(url_prefix, func_req_id, self.session)
|
self.downloadOrder = JsonHttpApiClient(DownloadOrder(), url_prefix, func_req_id, self.session)
|
||||||
self.confirmOrder = ConfirmOrder(url_prefix, func_req_id, self.session)
|
self.confirmOrder = JsonHttpApiClient(ConfirmOrder(), url_prefix, func_req_id, self.session)
|
||||||
self.cancelOrder = CancelOrder(url_prefix, func_req_id, self.session)
|
self.cancelOrder = JsonHttpApiClient(CancelOrder(), url_prefix, func_req_id, self.session)
|
||||||
self.releaseProfile = ReleaseProfile(url_prefix, func_req_id, self.session)
|
self.releaseProfile = JsonHttpApiClient(ReleaseProfile(), url_prefix, func_req_id, self.session)
|
||||||
self.handleDownloadProgressInfo = HandleDownloadProgressInfo(url_prefix, func_req_id, self.session)
|
self.handleDownloadProgressInfo = JsonHttpApiClient(HandleDownloadProgressInfo(), url_prefix, func_req_id, self.session)
|
||||||
|
|
||||||
def _gen_func_id(self) -> str:
|
def _gen_func_id(self) -> str:
|
||||||
"""Generate the next function call id."""
|
"""Generate the next function call id."""
|
||||||
self.func_id += 1
|
self.func_id += 1
|
||||||
return 'FCI-%u-%u' % (time.time(), self.func_id)
|
return 'FCI-%u-%u' % (time.time(), self.func_id)
|
||||||
|
|
||||||
|
|
||||||
def call_downloadOrder(self, data: dict) -> dict:
|
def call_downloadOrder(self, data: dict) -> dict:
|
||||||
"""Perform ES2+ DownloadOrder function (SGP.22 section 5.3.1)."""
|
"""Perform ES2+ DownloadOrder function (SGP.22 section 5.3.1)."""
|
||||||
return self.downloadOrder.call(data, self._gen_func_id())
|
return self.downloadOrder.call(data, self._gen_func_id())
|
||||||
@@ -237,3 +247,116 @@ class Es2pApiClient:
|
|||||||
def call_handleDownloadProgressInfo(self, data: dict) -> dict:
|
def call_handleDownloadProgressInfo(self, data: dict) -> dict:
|
||||||
"""Perform ES2+ HandleDownloadProgressInfo function (SGP.22 section 5.3.5)."""
|
"""Perform ES2+ HandleDownloadProgressInfo function (SGP.22 section 5.3.5)."""
|
||||||
return self.handleDownloadProgressInfo.call(data, self._gen_func_id())
|
return self.handleDownloadProgressInfo.call(data, self._gen_func_id())
|
||||||
|
|
||||||
|
class Es2pApiServerHandlerSmdpp(abc.ABC):
|
||||||
|
"""ES2+ (SMDP+ side) API Server handler class. The API user is expected to override the contained methods."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def call_downloadOrder(self, data: dict) -> (dict, str):
|
||||||
|
"""Perform ES2+ DownloadOrder function (SGP.22 section 5.3.1)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def call_confirmOrder(self, data: dict) -> (dict, str):
|
||||||
|
"""Perform ES2+ ConfirmOrder function (SGP.22 section 5.3.2)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def call_cancelOrder(self, data: dict) -> (dict, str):
|
||||||
|
"""Perform ES2+ CancelOrder function (SGP.22 section 5.3.3)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def call_releaseProfile(self, data: dict) -> (dict, str):
|
||||||
|
"""Perform ES2+ CancelOrder function (SGP.22 section 5.3.4)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Es2pApiServerHandlerMno(abc.ABC):
|
||||||
|
"""ES2+ (MNO side) API Server handler class. The API user is expected to override the contained methods."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def call_handleDownloadProgressInfo(self, data: dict) -> (dict, str):
|
||||||
|
"""Perform ES2+ HandleDownloadProgressInfo function (SGP.22 section 5.3.5)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Es2pApiServer(abc.ABC):
|
||||||
|
"""Main class representing a full ES2+ API server. Has one method for each API function."""
|
||||||
|
app = None
|
||||||
|
|
||||||
|
def __init__(self, port: int, interface: str, server_cert: str = None, client_cert_verify: str = None):
|
||||||
|
logger.debug("HTTP SRV: starting ES2+ API server on %s:%s" % (interface, port))
|
||||||
|
self.port = port
|
||||||
|
self.interface = interface
|
||||||
|
if server_cert:
|
||||||
|
self.server_cert = ssl.PrivateCertificate.loadPEM(Path(server_cert).read_text())
|
||||||
|
else:
|
||||||
|
self.server_cert = None
|
||||||
|
if client_cert_verify:
|
||||||
|
self.client_cert_verify = ssl.Certificate.loadPEM(Path(client_cert_verify).read_text())
|
||||||
|
else:
|
||||||
|
self.client_cert_verify = None
|
||||||
|
|
||||||
|
def reactor(self, reactor: PosixReactorBase):
|
||||||
|
logger.debug("HTTP SRV: listen on %s:%s" % (self.interface, self.port))
|
||||||
|
if self.server_cert:
|
||||||
|
if self.client_cert_verify:
|
||||||
|
reactor.listenSSL(self.port, Site(self.app.resource()), self.server_cert.options(self.client_cert_verify),
|
||||||
|
interface=self.interface)
|
||||||
|
else:
|
||||||
|
reactor.listenSSL(self.port, Site(self.app.resource()), self.server_cert.options(),
|
||||||
|
interface=self.interface)
|
||||||
|
else:
|
||||||
|
reactor.listenTCP(self.port, Site(self.app.resource()), interface=self.interface)
|
||||||
|
return defer.Deferred()
|
||||||
|
|
||||||
|
class Es2pApiServerSmdpp(Es2pApiServer):
|
||||||
|
"""ES2+ (SMDP+ side) API Server."""
|
||||||
|
app = Klein()
|
||||||
|
|
||||||
|
def __init__(self, port: int, interface: str, handler: Es2pApiServerHandlerSmdpp,
|
||||||
|
server_cert: str = None, client_cert_verify: str = None):
|
||||||
|
super().__init__(port, interface, server_cert, client_cert_verify)
|
||||||
|
self.handler = handler
|
||||||
|
self.downloadOrder = JsonHttpApiServer(DownloadOrder(), handler.call_downloadOrder)
|
||||||
|
self.confirmOrder = JsonHttpApiServer(ConfirmOrder(), handler.call_confirmOrder)
|
||||||
|
self.cancelOrder = JsonHttpApiServer(CancelOrder(), handler.call_cancelOrder)
|
||||||
|
self.releaseProfile = JsonHttpApiServer(ReleaseProfile(), handler.call_releaseProfile)
|
||||||
|
task.react(self.reactor)
|
||||||
|
|
||||||
|
@app.route(DownloadOrder.path)
|
||||||
|
def call_downloadOrder(self, request: Request) -> dict:
|
||||||
|
"""Perform ES2+ DownloadOrder function (SGP.22 section 5.3.1)."""
|
||||||
|
return self.downloadOrder.call(request)
|
||||||
|
|
||||||
|
@app.route(ConfirmOrder.path)
|
||||||
|
def call_confirmOrder(self, request: Request) -> dict:
|
||||||
|
"""Perform ES2+ ConfirmOrder function (SGP.22 section 5.3.2)."""
|
||||||
|
return self.confirmOrder.call(request)
|
||||||
|
|
||||||
|
@app.route(CancelOrder.path)
|
||||||
|
def call_cancelOrder(self, request: Request) -> dict:
|
||||||
|
"""Perform ES2+ CancelOrder function (SGP.22 section 5.3.3)."""
|
||||||
|
return self.cancelOrder.call(request)
|
||||||
|
|
||||||
|
@app.route(ReleaseProfile.path)
|
||||||
|
def call_releaseProfile(self, request: Request) -> dict:
|
||||||
|
"""Perform ES2+ CancelOrder function (SGP.22 section 5.3.4)."""
|
||||||
|
return self.releaseProfile.call(request)
|
||||||
|
|
||||||
|
class Es2pApiServerMno(Es2pApiServer):
|
||||||
|
"""ES2+ (MNO side) API Server."""
|
||||||
|
|
||||||
|
app = Klein()
|
||||||
|
|
||||||
|
def __init__(self, port: int, interface: str, handler: Es2pApiServerHandlerMno,
|
||||||
|
server_cert: str = None, client_cert_verify: str = None):
|
||||||
|
super().__init__(port, interface, server_cert, client_cert_verify)
|
||||||
|
self.handler = handler
|
||||||
|
self.handleDownloadProgressInfo = JsonHttpApiServer(HandleDownloadProgressInfo(),
|
||||||
|
handler.call_handleDownloadProgressInfo)
|
||||||
|
task.react(self.reactor)
|
||||||
|
|
||||||
|
@app.route(HandleDownloadProgressInfo.path)
|
||||||
|
def call_handleDownloadProgressInfo(self, request: Request) -> dict:
|
||||||
|
"""Perform ES2+ HandleDownloadProgressInfo function (SGP.22 section 5.3.5)."""
|
||||||
|
return self.handleDownloadProgressInfo.call(request)
|
||||||
|
|||||||
+5
-5
@@ -155,11 +155,11 @@ class Es9pApiClient:
|
|||||||
if server_cert_verify:
|
if server_cert_verify:
|
||||||
self.session.verify = server_cert_verify
|
self.session.verify = server_cert_verify
|
||||||
|
|
||||||
self.initiateAuthentication = InitiateAuthentication(url_prefix, '', self.session)
|
self.initiateAuthentication = JsonHttpApiClient(InitiateAuthentication(), url_prefix, '', self.session)
|
||||||
self.authenticateClient = AuthenticateClient(url_prefix, '', self.session)
|
self.authenticateClient = JsonHttpApiClient(AuthenticateClient(), url_prefix, '', self.session)
|
||||||
self.getBoundProfilePackage = GetBoundProfilePackage(url_prefix, '', self.session)
|
self.getBoundProfilePackage = JsonHttpApiClient(GetBoundProfilePackage(), url_prefix, '', self.session)
|
||||||
self.handleNotification = HandleNotification(url_prefix, '', self.session)
|
self.handleNotification = JsonHttpApiClient(HandleNotification(), url_prefix, '', self.session)
|
||||||
self.cancelSession = CancelSession(url_prefix, '', self.session)
|
self.cancelSession = JsonHttpApiClient(CancelSession(), url_prefix, '', self.session)
|
||||||
|
|
||||||
def call_initiateAuthentication(self, data: dict) -> dict:
|
def call_initiateAuthentication(self, data: dict) -> dict:
|
||||||
return self.initiateAuthentication.call(data)
|
return self.initiateAuthentication.call(data)
|
||||||
|
|||||||
+270
-39
@@ -19,8 +19,10 @@ import abc
|
|||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
import base64
|
import base64
|
||||||
|
from twisted.web.server import Request
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -131,6 +133,16 @@ class JsonResponseHeader(ApiParam):
|
|||||||
if status not in ['Executed-Success', 'Executed-WithWarning', 'Failed', 'Expired']:
|
if status not in ['Executed-Success', 'Executed-WithWarning', 'Failed', 'Expired']:
|
||||||
raise ValueError('Unknown/unspecified status "%s"' % status)
|
raise ValueError('Unknown/unspecified status "%s"' % status)
|
||||||
|
|
||||||
|
class JsonRequestHeader(ApiParam):
|
||||||
|
"""SGP.22 section 6.5.1.3."""
|
||||||
|
@classmethod
|
||||||
|
def verify_decoded(cls, data):
|
||||||
|
func_req_id = data.get('functionRequesterIdentifier')
|
||||||
|
if not func_req_id:
|
||||||
|
raise ValueError('Missing mandatory functionRequesterIdentifier in header')
|
||||||
|
func_call_id = data.get('functionCallIdentifier')
|
||||||
|
if not func_call_id:
|
||||||
|
raise ValueError('Missing mandatory functionCallIdentifier in header')
|
||||||
|
|
||||||
class HttpStatusError(Exception):
|
class HttpStatusError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -149,7 +161,8 @@ class ApiError(Exception):
|
|||||||
'message': None,
|
'message': None,
|
||||||
}
|
}
|
||||||
actual_sec = func_ex_status.get('statusCodeData', None)
|
actual_sec = func_ex_status.get('statusCodeData', None)
|
||||||
sec.update(actual_sec)
|
if actual_sec:
|
||||||
|
sec.update(actual_sec)
|
||||||
self.subject_code = sec['subjectCode']
|
self.subject_code = sec['subjectCode']
|
||||||
self.reason_code = sec['reasonCode']
|
self.reason_code = sec['reasonCode']
|
||||||
self.subject_id = sec['subjectIdentifier']
|
self.subject_id = sec['subjectIdentifier']
|
||||||
@@ -160,65 +173,118 @@ class ApiError(Exception):
|
|||||||
|
|
||||||
class JsonHttpApiFunction(abc.ABC):
|
class JsonHttpApiFunction(abc.ABC):
|
||||||
"""Base class for representing an HTTP[s] API Function."""
|
"""Base class for representing an HTTP[s] API Function."""
|
||||||
# the below class variables are expected to be overridden in derived classes
|
# The below class variables are used to describe the properties of the API function. Derived classes are expected
|
||||||
|
# to orverride those class properties with useful values. The prefixes "input_" and "output_" refer to the API
|
||||||
|
# function from an abstract point of view. Seen from the client perspective, "input_" will refer to parameters the
|
||||||
|
# client sends to a HTTP server. Seen from the server perspective, "input_" will refer to parameters the server
|
||||||
|
# receives from the a requesting client. The same applies vice versa to class variables that have an "output_"
|
||||||
|
# prefix.
|
||||||
|
|
||||||
|
# path of the API function (e.g. '/gsma/rsp2/es2plus/confirmOrder', see also method rewrite_url).
|
||||||
path = None
|
path = None
|
||||||
|
|
||||||
# dictionary of input parameters. key is parameter name, value is ApiParam class
|
# dictionary of input parameters. key is parameter name, value is ApiParam class
|
||||||
input_params = {}
|
input_params = {}
|
||||||
|
|
||||||
# list of mandatory input parameters
|
# list of mandatory input parameters
|
||||||
input_mandatory = []
|
input_mandatory = []
|
||||||
|
|
||||||
# dictionary of output parameters. key is parameter name, value is ApiParam class
|
# dictionary of output parameters. key is parameter name, value is ApiParam class
|
||||||
output_params = {}
|
output_params = {}
|
||||||
|
|
||||||
# list of mandatory output parameters (for successful response)
|
# list of mandatory output parameters (for successful response)
|
||||||
output_mandatory = []
|
output_mandatory = []
|
||||||
|
|
||||||
|
# list of mandatory output parameters (for failed response)
|
||||||
|
output_mandatory_failed = []
|
||||||
|
|
||||||
# expected HTTP status code of the response
|
# expected HTTP status code of the response
|
||||||
expected_http_status = 200
|
expected_http_status = 200
|
||||||
|
|
||||||
# the HTTP method used (GET, OPTIONS, HEAD, POST, PUT, PATCH or DELETE)
|
# the HTTP method used (GET, OPTIONS, HEAD, POST, PUT, PATCH or DELETE)
|
||||||
http_method = 'POST'
|
http_method = 'POST'
|
||||||
|
|
||||||
|
# additional custom HTTP headers (client requests)
|
||||||
extra_http_req_headers = {}
|
extra_http_req_headers = {}
|
||||||
|
|
||||||
def __init__(self, url_prefix: str, func_req_id: Optional[str], session: requests.Session):
|
# additional custom HTTP headers (server responses)
|
||||||
self.url_prefix = url_prefix
|
extra_http_res_headers = {}
|
||||||
self.func_req_id = func_req_id
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
def encode(self, data: dict, func_call_id: Optional[str] = None) -> dict:
|
def __new__(cls, *args, role = 'legacy_client', **kwargs):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
args: (see JsonHttpApiClient and JsonHttpApiServer)
|
||||||
|
role: role ('server' or 'client') in which the JsonHttpApiFunction should be created.
|
||||||
|
kwargs: (see JsonHttpApiClient and JsonHttpApiServer)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a dictionary with the class attributes of this class (the properties listed above and the encode_
|
||||||
|
# decode_ methods below). The dictionary will not include any dunder/magic methods
|
||||||
|
cls_attr = {attr_name: getattr(cls, attr_name) for attr_name in dir(cls) if not attr_name.startswith('__')}
|
||||||
|
|
||||||
|
# Normal instantiation as JsonHttpApiFunction:
|
||||||
|
if len(args) == 0 and len(kwargs) == 0:
|
||||||
|
return type(cls.__name__, (abc.ABC,), cls_attr)()
|
||||||
|
|
||||||
|
# Instantiation as as JsonHttpApiFunction with a JsonHttpApiClient or JsonHttpApiServer base
|
||||||
|
if role == 'legacy_client':
|
||||||
|
# Deprecated: With the advent of the server role (JsonHttpApiServer) the API had to be changed. To maintain
|
||||||
|
# compatibility with existing code (out-of-tree) the original behaviour and API interface and behaviour had
|
||||||
|
# to be preserved. Already existing JsonHttpApiFunction definitions will still work and the related objects
|
||||||
|
# may still be created on the original way: my_api_func = MyApiFunc(url_prefix, func_req_id, self.session)
|
||||||
|
logger.warning('implicit role (falling back to legacy JsonHttpApiClient) is deprecated, please specify role explcitly')
|
||||||
|
result = type(cls.__name__, (JsonHttpApiClient,), cls_attr)(None, *args, **kwargs)
|
||||||
|
result.api_func = result
|
||||||
|
result.legacy = True
|
||||||
|
return result
|
||||||
|
elif role == 'client':
|
||||||
|
# Create a JsonHttpApiFunction in client role
|
||||||
|
# Example: my_api_func = MyApiFunc(url_prefix, func_req_id, self.session, role='client')
|
||||||
|
result = type(cls.__name__, (JsonHttpApiClient,), cls_attr)(None, *args, **kwargs)
|
||||||
|
result.api_func = result
|
||||||
|
return result
|
||||||
|
elif role == 'server':
|
||||||
|
# Create a JsonHttpApiFunction in server role
|
||||||
|
# Example: my_api_func = MyApiFunc(url_prefix, func_req_id, self.session, role='server')
|
||||||
|
result = type(cls.__name__, (JsonHttpApiServer,), cls_attr)(None, *args, **kwargs)
|
||||||
|
result.api_func = result
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
raise ValueError('Invalid role \'%s\' specified' % role)
|
||||||
|
|
||||||
|
def encode_client(self, data: dict) -> dict:
|
||||||
"""Validate an encode input dict into JSON-serializable dict for request body."""
|
"""Validate an encode input dict into JSON-serializable dict for request body."""
|
||||||
output = {}
|
output = {}
|
||||||
if func_call_id:
|
|
||||||
output['header'] = {
|
|
||||||
'functionRequesterIdentifier': self.func_req_id,
|
|
||||||
'functionCallIdentifier': func_call_id
|
|
||||||
}
|
|
||||||
|
|
||||||
for p in self.input_mandatory:
|
for p in self.input_mandatory:
|
||||||
if not p in data:
|
if not p in data:
|
||||||
raise ValueError('Mandatory input parameter %s missing' % p)
|
raise ValueError('Mandatory input parameter %s missing' % p)
|
||||||
for p, v in data.items():
|
for p, v in data.items():
|
||||||
p_class = self.input_params.get(p)
|
p_class = self.input_params.get(p)
|
||||||
if not p_class:
|
if not p_class:
|
||||||
logger.warning('Unexpected/unsupported input parameter %s=%s', p, v)
|
# pySim/esim/http_json_api.py:269:47: E1101: Instance of 'JsonHttpApiFunction' has no 'legacy' member (no-member)
|
||||||
output[p] = v
|
# pylint: disable=no-member
|
||||||
|
if hasattr(self, 'legacy') and self.legacy:
|
||||||
|
output[p] = JsonRequestHeader.encode(v)
|
||||||
|
else:
|
||||||
|
logger.warning('Unexpected/unsupported input parameter %s=%s', p, v)
|
||||||
|
output[p] = v
|
||||||
else:
|
else:
|
||||||
output[p] = p_class.encode(v)
|
output[p] = p_class.encode(v)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def decode(self, data: dict) -> dict:
|
def decode_client(self, data: dict) -> dict:
|
||||||
"""[further] Decode and validate the JSON-Dict of the response body."""
|
"""[further] Decode and validate the JSON-Dict of the response body."""
|
||||||
output = {}
|
output = {}
|
||||||
if 'header' in self.output_params:
|
output_mandatory = self.output_mandatory
|
||||||
# let's first do the header, it's special
|
|
||||||
if not 'header' in data:
|
|
||||||
raise ValueError('Mandatory output parameter "header" missing')
|
|
||||||
hdr_class = self.output_params.get('header')
|
|
||||||
output['header'] = hdr_class.decode(data['header'])
|
|
||||||
|
|
||||||
if output['header']['functionExecutionStatus']['status'] not in ['Executed-Success','Executed-WithWarning']:
|
# In case a provided header (may be optional) indicates that the API function call was unsuccessful, a
|
||||||
raise ApiError(output['header']['functionExecutionStatus'])
|
# different set of mandatory parameters applies.
|
||||||
# we can only expect mandatory parameters to be present in case of successful execution
|
header = data.get('header')
|
||||||
for p in self.output_mandatory:
|
if header:
|
||||||
if p == 'header':
|
if data['header']['functionExecutionStatus']['status'] not in ['Executed-Success','Executed-WithWarning']:
|
||||||
continue
|
output_mandatory = self.output_mandatory_failed
|
||||||
|
|
||||||
|
for p in output_mandatory:
|
||||||
if not p in data:
|
if not p in data:
|
||||||
raise ValueError('Mandatory output parameter "%s" missing' % p)
|
raise ValueError('Mandatory output parameter "%s" missing' % p)
|
||||||
for p, v in data.items():
|
for p, v in data.items():
|
||||||
@@ -230,30 +296,195 @@ class JsonHttpApiFunction(abc.ABC):
|
|||||||
output[p] = p_class.decode(v)
|
output[p] = p_class.decode(v)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def encode_server(self, data: dict) -> dict:
|
||||||
|
"""Validate an encode input dict into JSON-serializable dict for response body."""
|
||||||
|
output = {}
|
||||||
|
output_mandatory = self.output_mandatory
|
||||||
|
|
||||||
|
# In case a provided header (may be optional) indicates that the API function call was unsuccessful, a
|
||||||
|
# different set of mandatory parameters applies.
|
||||||
|
header = data.get('header')
|
||||||
|
if header:
|
||||||
|
if data['header']['functionExecutionStatus']['status'] not in ['Executed-Success','Executed-WithWarning']:
|
||||||
|
output_mandatory = self.output_mandatory_failed
|
||||||
|
|
||||||
|
for p in output_mandatory:
|
||||||
|
if not p in data:
|
||||||
|
raise ValueError('Mandatory output parameter %s missing' % p)
|
||||||
|
for p, v in data.items():
|
||||||
|
p_class = self.output_params.get(p)
|
||||||
|
if not p_class:
|
||||||
|
logger.warning('Unexpected/unsupported output parameter %s=%s', p, v)
|
||||||
|
output[p] = v
|
||||||
|
else:
|
||||||
|
output[p] = p_class.encode(v)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def decode_server(self, data: dict) -> dict:
|
||||||
|
"""[further] Decode and validate the JSON-Dict of the request body."""
|
||||||
|
output = {}
|
||||||
|
|
||||||
|
for p in self.input_mandatory:
|
||||||
|
if not p in data:
|
||||||
|
raise ValueError('Mandatory input parameter "%s" missing' % p)
|
||||||
|
for p, v in data.items():
|
||||||
|
p_class = self.input_params.get(p)
|
||||||
|
if not p_class:
|
||||||
|
logger.warning('Unexpected/unsupported input parameter "%s"="%s"', p, v)
|
||||||
|
output[p] = v
|
||||||
|
else:
|
||||||
|
output[p] = p_class.decode(v)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def rewrite_url(self, data: dict, url: str) -> Tuple[dict, str]:
|
||||||
|
"""
|
||||||
|
Rewrite a static URL using information passed in the data dict. This method may be overloaded by a derived
|
||||||
|
class to allow fully dynamic URLs. The input parameters required for the URL rewriting may be passed using
|
||||||
|
data parameter. In case those parameters are additional parameters that are not intended to be passed to
|
||||||
|
the encode_client method later, they must be removed explcitly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: (see JsonHttpApiClient and JsonHttpApiServer)
|
||||||
|
url: statically generated URL string (see comment in JsonHttpApiClient)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# This implementation is a placeholder in which we do not perform any URL rewriting. We just pass through data
|
||||||
|
# and url unmodified.
|
||||||
|
return data, url
|
||||||
|
|
||||||
|
class JsonHttpApiClient():
|
||||||
|
def __init__(self, api_func: JsonHttpApiFunction, url_prefix: str, func_req_id: Optional[str],
|
||||||
|
session: requests.Session):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
api_func : API function definition (JsonHttpApiFunction)
|
||||||
|
url_prefix : prefix to be put in front of the API function path (see JsonHttpApiFunction)
|
||||||
|
func_req_id : function requestor id to use for requests
|
||||||
|
session : session object (requests)
|
||||||
|
"""
|
||||||
|
self.api_func = api_func
|
||||||
|
self.url_prefix = url_prefix
|
||||||
|
self.func_req_id = func_req_id
|
||||||
|
self.session = session
|
||||||
|
|
||||||
def call(self, data: dict, func_call_id: Optional[str] = None, timeout=10) -> Optional[dict]:
|
def call(self, data: dict, func_call_id: Optional[str] = None, timeout=10) -> Optional[dict]:
|
||||||
"""Make an API call to the HTTP API endpoint represented by this object.
|
"""
|
||||||
Input data is passed in `data` as json-serializable dict. Output data
|
Make an API call to the HTTP API endpoint represented by this object. Input data is passed in `data` as
|
||||||
is returned as json-deserialized dict."""
|
json-serializable fields. `data` may also contain additional parameters required for URL rewriting (see
|
||||||
url = self.url_prefix + self.path
|
rewrite_url in class JsonHttpApiFunction). Output data is returned as json-deserialized dict.
|
||||||
encoded = json.dumps(self.encode(data, func_call_id))
|
|
||||||
|
Args:
|
||||||
|
data: Input data required to perform the request.
|
||||||
|
func_call_id: Function Call Identifier, if present a header field is generated automatically.
|
||||||
|
timeout: Maximum amount of time to wait for the request to complete.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# In case a function caller ID is supplied, use it together with the stored function requestor ID to generate
|
||||||
|
# and prepend the header field according to SGP.22, section 6.5.1.1 and 6.5.1.3. (the presence of the header
|
||||||
|
# field is checked by the encode_client method)
|
||||||
|
if func_call_id:
|
||||||
|
data = {'header' : {'functionRequesterIdentifier': self.func_req_id,
|
||||||
|
'functionCallIdentifier': func_call_id}} | data
|
||||||
|
|
||||||
|
# The URL used for the HTTP request (see below) normally consists of the initially given url_prefix
|
||||||
|
# concatenated with the path defined by the JsonHttpApiFunction definition. This static URL path may be
|
||||||
|
# rewritten by rewrite_url method defined in the JsonHttpApiFunction.
|
||||||
|
data, url = self.api_func.rewrite_url(data, self.url_prefix + self.api_func.path)
|
||||||
|
|
||||||
|
# Encode the message (the presence of mandatory fields is checked during encoding)
|
||||||
|
encoded = json.dumps(self.api_func.encode_client(data))
|
||||||
|
|
||||||
|
# Apply HTTP request headers according to SGP.22, section 6.5.1
|
||||||
req_headers = {
|
req_headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Admin-Protocol': 'gsma/rsp/v2.5.0',
|
'X-Admin-Protocol': 'gsma/rsp/v2.5.0',
|
||||||
}
|
}
|
||||||
req_headers.update(self.extra_http_req_headers)
|
req_headers.update(self.api_func.extra_http_req_headers)
|
||||||
|
|
||||||
|
# Perform HTTP request
|
||||||
logger.debug("HTTP REQ %s - hdr: %s '%s'" % (url, req_headers, encoded))
|
logger.debug("HTTP REQ %s - hdr: %s '%s'" % (url, req_headers, encoded))
|
||||||
response = self.session.request(self.http_method, url, data=encoded, headers=req_headers, timeout=timeout)
|
response = self.session.request(self.api_func.http_method, url, data=encoded, headers=req_headers, timeout=timeout)
|
||||||
logger.debug("HTTP RSP-STS: [%u] hdr: %s" % (response.status_code, response.headers))
|
logger.debug("HTTP RSP-STS: [%u] hdr: %s" % (response.status_code, response.headers))
|
||||||
logger.debug("HTTP RSP: %s" % (response.content))
|
logger.debug("HTTP RSP: %s" % (response.content))
|
||||||
|
|
||||||
if response.status_code != self.expected_http_status:
|
# Check HTTP response status code and make sure that the returned HTTP headers look plausible (according to
|
||||||
|
# SGP.22, section 6.5.1)
|
||||||
|
if response.status_code != self.api_func.expected_http_status:
|
||||||
raise HttpStatusError(response)
|
raise HttpStatusError(response)
|
||||||
if not response.headers.get('Content-Type').startswith(req_headers['Content-Type']):
|
if response.content and not response.headers.get('Content-Type').startswith(req_headers['Content-Type']):
|
||||||
raise HttpHeaderError(response)
|
raise HttpHeaderError(response)
|
||||||
if not response.headers.get('X-Admin-Protocol', 'gsma/rsp/v2.unknown').startswith('gsma/rsp/v2.'):
|
if not response.headers.get('X-Admin-Protocol', 'gsma/rsp/v2.unknown').startswith('gsma/rsp/v2.'):
|
||||||
raise HttpHeaderError(response)
|
raise HttpHeaderError(response)
|
||||||
|
|
||||||
|
# Decode response and return the result back to the caller
|
||||||
if response.content:
|
if response.content:
|
||||||
return self.decode(response.json())
|
output = self.api_func.decode_client(response.json())
|
||||||
|
# In case the response contains a header, check it to make sure that the API call was executed successfully
|
||||||
|
# (the presence of the header field is checked by the decode_client method)
|
||||||
|
if 'header' in output:
|
||||||
|
if output['header']['functionExecutionStatus']['status'] not in ['Executed-Success','Executed-WithWarning']:
|
||||||
|
raise ApiError(output['header']['functionExecutionStatus'])
|
||||||
|
return output
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
class JsonHttpApiServer():
|
||||||
|
def __init__(self, api_func: JsonHttpApiFunction, call_handler = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
api_func : API function definition (JsonHttpApiFunction)
|
||||||
|
call_handler : handler function to process the request. This function must accept the
|
||||||
|
decoded request as a dictionary. The handler function must return a tuple consisting
|
||||||
|
of the response in the form of a dictionary (may be empty), and a function execution
|
||||||
|
status string ('Executed-Success', 'Executed-WithWarning', 'Failed' or 'Expired')
|
||||||
|
"""
|
||||||
|
self.api_func = api_func
|
||||||
|
if call_handler:
|
||||||
|
self.call_handler = call_handler
|
||||||
|
else:
|
||||||
|
self.call_handler = self.default_handler
|
||||||
|
|
||||||
|
def default_handler(self, data: dict) -> (dict, str):
|
||||||
|
"""default handler, used in case no call handler is provided."""
|
||||||
|
logger.error("no handler function for request: %s" % str(data))
|
||||||
|
return {}, 'Failed'
|
||||||
|
|
||||||
|
def call(self, request: Request) -> str:
|
||||||
|
""" Process an incoming request.
|
||||||
|
Args:
|
||||||
|
request : request object as received using twisted.web.server
|
||||||
|
Returns:
|
||||||
|
encoded JSON string (HTTP response code and headers are set by calling the appropriate methods on the
|
||||||
|
provided the request object)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Make sure the request is done with the correct HTTP method
|
||||||
|
if (request.method.decode() != self.api_func.http_method):
|
||||||
|
raise ValueError('Wrong HTTP method %s!=%s' % (request.method.decode(), self.api_func.http_method))
|
||||||
|
|
||||||
|
# Decode the request
|
||||||
|
decoded_request = self.api_func.decode_server(json.loads(request.content.read()))
|
||||||
|
|
||||||
|
# Run call handler (see above)
|
||||||
|
data, fe_status = self.call_handler(decoded_request)
|
||||||
|
|
||||||
|
# In case a function execution status is returned, use it to generate and prepend the header field according to
|
||||||
|
# SGP.22, section 6.5.1.2 and 6.5.1.4 (the presence of the header filed is checked by the encode_server method)
|
||||||
|
if fe_status:
|
||||||
|
data = {'header' : {'functionExecutionStatus': {'status' : fe_status}}} | data
|
||||||
|
|
||||||
|
# Encode the message (the presence of mandatory fields is checked during encoding)
|
||||||
|
encoded = json.dumps(self.api_func.encode_server(data))
|
||||||
|
|
||||||
|
# Apply HTTP request headers according to SGP.22, section 6.5.1
|
||||||
|
res_headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Protocol': 'gsma/rsp/v2.5.0',
|
||||||
|
}
|
||||||
|
res_headers.update(self.api_func.extra_http_res_headers)
|
||||||
|
for header, value in res_headers.items():
|
||||||
|
request.setHeader(header, value)
|
||||||
|
request.setResponseCode(self.api_func.expected_http_status)
|
||||||
|
|
||||||
|
# Return the encoded result back to the caller for sending (using twisted/klein)
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|||||||
+147
-19
@@ -21,6 +21,8 @@ import io
|
|||||||
import os
|
import os
|
||||||
from typing import Tuple, List, Optional, Dict, Union
|
from typing import Tuple, List, Optional, Dict, Union
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from difflib import SequenceMatcher, Match
|
||||||
|
|
||||||
import asn1tools
|
import asn1tools
|
||||||
import zipfile
|
import zipfile
|
||||||
from pySim import javacard
|
from pySim import javacard
|
||||||
@@ -32,7 +34,7 @@ from pySim import ts_102_222
|
|||||||
from pySim.utils import dec_imsi
|
from pySim.utils import dec_imsi
|
||||||
from pySim.ts_102_221 import FileDescriptor
|
from pySim.ts_102_221 import FileDescriptor
|
||||||
from pySim.filesystem import CardADF, Path
|
from pySim.filesystem import CardADF, Path
|
||||||
from pySim.ts_31_102 import ADF_USIM
|
from pySim.ts_31_102 import ADF_USIM, EF_UST, EF_SUCI_Calc_Info
|
||||||
from pySim.ts_31_103 import ADF_ISIM
|
from pySim.ts_31_103 import ADF_ISIM
|
||||||
from pySim.esim import compile_asn1_subdir
|
from pySim.esim import compile_asn1_subdir
|
||||||
from pySim.esim.saip import templates
|
from pySim.esim.saip import templates
|
||||||
@@ -44,6 +46,29 @@ asn1 = compile_asn1_subdir('saip')
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class NonMatch(Match):
|
||||||
|
"""Representing a contiguous non-matching block of data; the opposite of difflib.Match"""
|
||||||
|
@classmethod
|
||||||
|
def from_matchlist(cls, l: List[Match], size:int) -> List['NonMatch']:
|
||||||
|
"""Build a list of non-matching blocks of data from its inverse (list of matching blocks).
|
||||||
|
The caller must ensure that the input list is ordered, non-overlapping and only contains
|
||||||
|
matches at equal offsets in a and b."""
|
||||||
|
res = []
|
||||||
|
cur = 0
|
||||||
|
for match in l:
|
||||||
|
if match.a != match.b:
|
||||||
|
raise ValueError('only works for equal-offset matches')
|
||||||
|
assert match.a >= cur
|
||||||
|
nm_len = match.a - cur
|
||||||
|
if nm_len > 0:
|
||||||
|
# there's no point in generating zero-lenth non-matching sections
|
||||||
|
res.append(cls(a=cur, b=cur, size=nm_len))
|
||||||
|
cur = match.a + match.size
|
||||||
|
if size > cur:
|
||||||
|
res.append(cls(a=cur, b=cur, size=size-cur))
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
class Naa:
|
class Naa:
|
||||||
"""A class defining a Network Access Application (NAA)"""
|
"""A class defining a Network Access Application (NAA)"""
|
||||||
name = None
|
name = None
|
||||||
@@ -126,6 +151,8 @@ class File:
|
|||||||
self.df_name = None
|
self.df_name = None
|
||||||
self.fill_pattern = None
|
self.fill_pattern = None
|
||||||
self.fill_pattern_repeat = False
|
self.fill_pattern_repeat = False
|
||||||
|
self.pstdo = None # pinStatusTemplateDO, mandatory for DF/ADF
|
||||||
|
self.lcsi = None # optional life cycle status indicator
|
||||||
# apply some defaults from profile
|
# apply some defaults from profile
|
||||||
if self.template:
|
if self.template:
|
||||||
self.from_template(self.template)
|
self.from_template(self.template)
|
||||||
@@ -144,6 +171,9 @@ class File:
|
|||||||
def file_size(self) -> Optional[int]:
|
def file_size(self) -> Optional[int]:
|
||||||
"""Return the size of the file in bytes."""
|
"""Return the size of the file in bytes."""
|
||||||
if self.file_type in ['LF', 'CY']:
|
if self.file_type in ['LF', 'CY']:
|
||||||
|
if self._file_size and self.nb_rec is None and self.rec_len:
|
||||||
|
self.nb_rec = self._file_size // self.rec_len
|
||||||
|
|
||||||
return self.nb_rec * self.rec_len
|
return self.nb_rec * self.rec_len
|
||||||
elif self.file_type in ['TR', 'BT']:
|
elif self.file_type in ['TR', 'BT']:
|
||||||
return self._file_size
|
return self._file_size
|
||||||
@@ -250,6 +280,8 @@ class File:
|
|||||||
elif self.file_type in ['MF', 'DF', 'ADF']:
|
elif self.file_type in ['MF', 'DF', 'ADF']:
|
||||||
fdb_dec['file_type'] = 'df'
|
fdb_dec['file_type'] = 'df'
|
||||||
fdb_dec['structure'] = 'no_info_given'
|
fdb_dec['structure'] = 'no_info_given'
|
||||||
|
# pinStatusTemplateDO is mandatory for DF/ADF
|
||||||
|
fileDescriptor['pinStatusTemplateDO'] = self.pstdo
|
||||||
# build file descriptor based on above input data
|
# build file descriptor based on above input data
|
||||||
fd_dict = {}
|
fd_dict = {}
|
||||||
if len(fdb_dec):
|
if len(fdb_dec):
|
||||||
@@ -276,6 +308,8 @@ class File:
|
|||||||
# desired fill or repeat pattern in the "proprietaryEFInfo" element for the EF in Profiles
|
# desired fill or repeat pattern in the "proprietaryEFInfo" element for the EF in Profiles
|
||||||
# downloaded to a V2.2 or earlier eUICC.
|
# downloaded to a V2.2 or earlier eUICC.
|
||||||
fileDescriptor['proprietaryEFInfo'] = pefi
|
fileDescriptor['proprietaryEFInfo'] = pefi
|
||||||
|
if self.lcsi:
|
||||||
|
fileDescriptor['lcsi'] = self.lcsi
|
||||||
logger.debug("%s: to_fileDescriptor(%s)" % (self, fileDescriptor))
|
logger.debug("%s: to_fileDescriptor(%s)" % (self, fileDescriptor))
|
||||||
return fileDescriptor
|
return fileDescriptor
|
||||||
|
|
||||||
@@ -291,6 +325,12 @@ class File:
|
|||||||
dfName = fileDescriptor.get('dfName', None)
|
dfName = fileDescriptor.get('dfName', None)
|
||||||
if dfName:
|
if dfName:
|
||||||
self.df_name = dfName
|
self.df_name = dfName
|
||||||
|
efFileSize = fileDescriptor.get('efFileSize', None)
|
||||||
|
if efFileSize:
|
||||||
|
self._file_size = self._decode_file_size(efFileSize)
|
||||||
|
|
||||||
|
self.pstdo = fileDescriptor.get('pinStatusTemplateDO', None)
|
||||||
|
self.lcsi = fileDescriptor.get('lcsi', None)
|
||||||
pefi = fileDescriptor.get('proprietaryEFInfo', {})
|
pefi = fileDescriptor.get('proprietaryEFInfo', {})
|
||||||
securityAttributesReferenced = fileDescriptor.get('securityAttributesReferenced', None)
|
securityAttributesReferenced = fileDescriptor.get('securityAttributesReferenced', None)
|
||||||
if securityAttributesReferenced:
|
if securityAttributesReferenced:
|
||||||
@@ -300,13 +340,11 @@ class File:
|
|||||||
fdb_dec = fd_dec['file_descriptor_byte']
|
fdb_dec = fd_dec['file_descriptor_byte']
|
||||||
self.shareable = fdb_dec['shareable']
|
self.shareable = fdb_dec['shareable']
|
||||||
if fdb_dec['file_type'] == 'working_ef':
|
if fdb_dec['file_type'] == 'working_ef':
|
||||||
efFileSize = fileDescriptor.get('efFileSize', None)
|
|
||||||
if fd_dec['num_of_rec']:
|
if fd_dec['num_of_rec']:
|
||||||
self.nb_rec = fd_dec['num_of_rec']
|
self.nb_rec = fd_dec['num_of_rec']
|
||||||
if fd_dec['record_len']:
|
if fd_dec['record_len']:
|
||||||
self.rec_len = fd_dec['record_len']
|
self.rec_len = fd_dec['record_len']
|
||||||
if efFileSize:
|
if efFileSize:
|
||||||
self._file_size = self._decode_file_size(efFileSize)
|
|
||||||
if self.rec_len and self.nb_rec == None:
|
if self.rec_len and self.nb_rec == None:
|
||||||
# compute the number of records from file size and record length
|
# compute the number of records from file size and record length
|
||||||
self.nb_rec = self._file_size // self.rec_len
|
self.nb_rec = self._file_size // self.rec_len
|
||||||
@@ -403,15 +441,43 @@ class File:
|
|||||||
elif k == 'fillFileContent':
|
elif k == 'fillFileContent':
|
||||||
stream.write(v)
|
stream.write(v)
|
||||||
else:
|
else:
|
||||||
return ValueError("Unknown key '%s' in tuple list" % k)
|
raise ValueError("Unknown key '%s' in tuple list" % k)
|
||||||
return stream.getvalue()
|
return stream.getvalue()
|
||||||
|
|
||||||
def file_content_to_tuples(self) -> List[Tuple]:
|
def file_content_to_tuples(self, optimize:bool = False) -> List[Tuple]:
|
||||||
# FIXME: simplistic approach. needs optimization. We should first check if the content
|
"""Encode the file contents into a list of fillFileContent / fillFileOffset tuples that can be fed
|
||||||
# matches the expanded default value from the template. If it does, return empty list.
|
into the asn.1 encoder. If optimize is True, it will try to encode only the differences from the
|
||||||
# Next, we should compute the diff between the default value and self.body, and encode
|
fillFileContent of the profile template. Otherwise, the entire file contents will be encoded
|
||||||
# that as a sequence of fillFileOffset and fillFileContent tuples.
|
as-is."""
|
||||||
return [('fillFileContent', self.body)]
|
if not self.file_type in ['TR', 'LF', 'CY', 'BT']:
|
||||||
|
return []
|
||||||
|
if not optimize:
|
||||||
|
# simplistic approach: encode the full file, ignoring the template/default
|
||||||
|
return [('fillFileContent', self.body)]
|
||||||
|
# Try to 'compress' the file body, based on the default file contents.
|
||||||
|
if self.template:
|
||||||
|
default = self.template.expand_default_value_pattern(length=len(self.body))
|
||||||
|
if not default:
|
||||||
|
sm = SequenceMatcher(a=b'\xff'*len(self.body), b=self.body)
|
||||||
|
else:
|
||||||
|
if default == self.body:
|
||||||
|
# 100% match: return an empty tuple list to make eUICC use the default
|
||||||
|
return []
|
||||||
|
sm = SequenceMatcher(a=default, b=self.body)
|
||||||
|
else:
|
||||||
|
# no template at all: we can only remove padding
|
||||||
|
sm = SequenceMatcher(a=b'\xff'*len(self.body), b=self.body)
|
||||||
|
matching_blocks = sm.get_matching_blocks()
|
||||||
|
# we can only make use of matches that have the same offset in 'a' and 'b'
|
||||||
|
matching_blocks = [x for x in matching_blocks if x.size > 0 and x.a == x.b]
|
||||||
|
non_matching_blocks = NonMatch.from_matchlist(matching_blocks, self.file_size)
|
||||||
|
ret = []
|
||||||
|
cur = 0
|
||||||
|
for block in non_matching_blocks:
|
||||||
|
ret.append(('fillFileOffset', block.a - cur))
|
||||||
|
ret.append(('fillFileContent', self.body[block.a:block.a+block.size]))
|
||||||
|
cur += block.size
|
||||||
|
return ret
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return "File(%s)" % self.pe_name
|
return "File(%s)" % self.pe_name
|
||||||
@@ -633,8 +699,15 @@ class FsProfileElement(ProfileElement):
|
|||||||
self.pe_sequence.cur_df = pe_df
|
self.pe_sequence.cur_df = pe_df
|
||||||
self.pe_sequence.cur_df = self.pe_sequence.cur_df.add_file(file)
|
self.pe_sequence.cur_df = self.pe_sequence.cur_df.add_file(file)
|
||||||
|
|
||||||
|
def file2pe(self, file: File):
|
||||||
|
"""Update the "decoded" member for the given file with the contents from the given File instance.
|
||||||
|
We expect that the File instance is part of self.files"""
|
||||||
|
if self.files[file.pe_name] != file:
|
||||||
|
raise ValueError("The file you passed is not part of this ProfileElement")
|
||||||
|
self.decoded[file.pe_name] = file.to_tuples()
|
||||||
|
|
||||||
def files2pe(self):
|
def files2pe(self):
|
||||||
"""Update the "decoded" member with the contents of the "files" member."""
|
"""Update the "decoded" member for each file with the contents of the "files" member."""
|
||||||
for k, f in self.files.items():
|
for k, f in self.files.items():
|
||||||
self.decoded[k] = f.to_tuples()
|
self.decoded[k] = f.to_tuples()
|
||||||
|
|
||||||
@@ -1006,6 +1079,13 @@ class SecurityDomainKey:
|
|||||||
'keyVersionNumber': bytes([self.key_version_number]),
|
'keyVersionNumber': bytes([self.key_version_number]),
|
||||||
'keyComponents': [k.to_saip_dict() for k in self.key_components]}
|
'keyComponents': [k.to_saip_dict() for k in self.key_components]}
|
||||||
|
|
||||||
|
def get_key_component(self, key_type):
|
||||||
|
for kc in self.key_components:
|
||||||
|
if kc.key_type == key_type:
|
||||||
|
return kc.key_data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ProfileElementSD(ProfileElement):
|
class ProfileElementSD(ProfileElement):
|
||||||
"""Class representing a securityDomain ProfileElement."""
|
"""Class representing a securityDomain ProfileElement."""
|
||||||
type = 'securityDomain'
|
type = 'securityDomain'
|
||||||
@@ -1020,6 +1100,7 @@ class ProfileElementSD(ProfileElement):
|
|||||||
def __init__(self, decoded: Optional[dict] = None, **kwargs):
|
def __init__(self, decoded: Optional[dict] = None, **kwargs):
|
||||||
super().__init__(decoded, **kwargs)
|
super().__init__(decoded, **kwargs)
|
||||||
if decoded:
|
if decoded:
|
||||||
|
self._post_decode()
|
||||||
return
|
return
|
||||||
# provide some reasonable defaults for a MNO-SD
|
# provide some reasonable defaults for a MNO-SD
|
||||||
self.decoded['instance'] = {
|
self.decoded['instance'] = {
|
||||||
@@ -1645,7 +1726,52 @@ class ProfileElementSequence:
|
|||||||
if 'BT' in ftype_list:
|
if 'BT' in ftype_list:
|
||||||
svc_set.add('ber-tlv')
|
svc_set.add('ber-tlv')
|
||||||
# FIXME:dfLinked files (scan all files, check for non-empty Fcp.linkPath presence of DFs)
|
# FIXME:dfLinked files (scan all files, check for non-empty Fcp.linkPath presence of DFs)
|
||||||
# TODO: 5G related bits (derive from EF.UST or file presence?)
|
|
||||||
|
# 5G:
|
||||||
|
# - When SUCI is:
|
||||||
|
# - enabled (EF.UST 124 = true)
|
||||||
|
# AND
|
||||||
|
# - calculated in the USIM (EF.UST 125 = true),
|
||||||
|
# then eUICC-Mandatory-services needs 'get-identity'.
|
||||||
|
# - 'get-identity' implies that the eUICC must support ONE OF profile-A OR profile-B.
|
||||||
|
# (One might assume from this that, when SUCI-CalcInfo for USIM in DF.SAIP contains both key types, then no
|
||||||
|
# profile-A or B services need to be requested explicitly. However, the correct logic is:)
|
||||||
|
# - Iff the SUCI-CalcInfo for USIM (DF.SAIP) contains a key of profile-A ("identifier": 1),
|
||||||
|
# then eUICC-Mandatory-services needs 'profile-a-x25519'.
|
||||||
|
# - Same: profile-B ("identifier": 2) needs 'profile-b-p256'.
|
||||||
|
# - (When SUCI is calculated in the UE, then the eUICC does not need to provide any of these services.)
|
||||||
|
suci_in_usim_enabled = False
|
||||||
|
try:
|
||||||
|
f_ust = self.get_pe_for_type("usim").files["ef-ust"]
|
||||||
|
ust = EF_UST().decode_bin(f_ust.body)
|
||||||
|
suci_in_usim_enabled = ust[124]['activated'] and ust[125]['activated']
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
pass
|
||||||
|
if suci_in_usim_enabled:
|
||||||
|
svc_set.add('get-identity')
|
||||||
|
# now check for profile-a and profile-b presence
|
||||||
|
suci_calcinfo_has_profile_a = False
|
||||||
|
suci_calcinfo_has_profile_b = False
|
||||||
|
try:
|
||||||
|
f_sucici = self.get_pe_for_type("df-saip").files["ef-suci-calc-info-usim"]
|
||||||
|
sucici = EF_SUCI_Calc_Info().decode_bin(f_sucici.body) or {}
|
||||||
|
for prot_scheme in sucici['prot_scheme_id_list']:
|
||||||
|
if not isinstance(prot_scheme, dict):
|
||||||
|
continue
|
||||||
|
ps_id = prot_scheme["identifier"]
|
||||||
|
if ps_id == 1:
|
||||||
|
suci_calcinfo_has_profile_a = True
|
||||||
|
elif ps_id == 2:
|
||||||
|
suci_calcinfo_has_profile_b = True
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
pass
|
||||||
|
if suci_calcinfo_has_profile_a:
|
||||||
|
# The profile has a profile-A key, so require that
|
||||||
|
svc_set.add('profile-a-x25519')
|
||||||
|
if suci_calcinfo_has_profile_b:
|
||||||
|
# The profile has a profile-B key, so require that
|
||||||
|
svc_set.add('profile-b-p256')
|
||||||
|
|
||||||
hdr_pe = self.get_pe_for_type('header')
|
hdr_pe = self.get_pe_for_type('header')
|
||||||
# patch in the 'manual' services from the existing list:
|
# patch in the 'manual' services from the existing list:
|
||||||
for old_svc in hdr_pe.decoded['eUICC-Mandatory-services'].keys():
|
for old_svc in hdr_pe.decoded['eUICC-Mandatory-services'].keys():
|
||||||
@@ -1738,8 +1864,7 @@ class ProfileElementSequence:
|
|||||||
del hdr.decoded['eUICC-Mandatory-services'][service]
|
del hdr.decoded['eUICC-Mandatory-services'][service]
|
||||||
# remove any associated mandatory filesystem templates
|
# remove any associated mandatory filesystem templates
|
||||||
for template in naa.templates:
|
for template in naa.templates:
|
||||||
if template in hdr.decoded['eUICC-Mandatory-GFSTEList']:
|
hdr.decoded['eUICC-Mandatory-GFSTEList'] = [x for x in hdr.decoded['eUICC-Mandatory-GFSTEList'] if not template.prefix_match(x)]
|
||||||
hdr.decoded['eUICC-Mandatory-GFSTEList'] = [x for x in hdr.decoded['eUICC-Mandatory-GFSTEList'] if not template.prefix_match(x)]
|
|
||||||
# determine the ADF names (AIDs) of all NAA ADFs
|
# determine the ADF names (AIDs) of all NAA ADFs
|
||||||
naa_adf_names = []
|
naa_adf_names = []
|
||||||
if naa.pe_types[0] in self.pe_by_type:
|
if naa.pe_types[0] in self.pe_by_type:
|
||||||
@@ -1782,7 +1907,7 @@ class ProfileElementSequence:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def peclass_for_path(path: Path) -> Optional[ProfileElement]:
|
def peclass_for_path(path: Path) -> Tuple[Optional[ProfileElement], Optional[templates.FileTemplate]]:
|
||||||
"""Return the ProfileElement class that can contain a file with given path."""
|
"""Return the ProfileElement class that can contain a file with given path."""
|
||||||
naa = ProfileElementSequence.naa_for_path(path)
|
naa = ProfileElementSequence.naa_for_path(path)
|
||||||
if naa:
|
if naa:
|
||||||
@@ -1815,7 +1940,7 @@ class ProfileElementSequence:
|
|||||||
return ProfileElementTelecom, ft
|
return ProfileElementTelecom, ft
|
||||||
return ProfileElementGFM, None
|
return ProfileElementGFM, None
|
||||||
|
|
||||||
def pe_for_path(self, path: Path) -> Optional[ProfileElement]:
|
def pe_for_path(self, path: Path) -> Tuple[Optional[ProfileElement], Optional[templates.FileTemplate]]:
|
||||||
"""Return the ProfileElement instance that can contain a file with matching path. This will
|
"""Return the ProfileElement instance that can contain a file with matching path. This will
|
||||||
either be an existing PE within the sequence, or it will be a newly-allocated PE that is
|
either be an existing PE within the sequence, or it will be a newly-allocated PE that is
|
||||||
inserted into the sequence."""
|
inserted into the sequence."""
|
||||||
@@ -1881,7 +2006,10 @@ class ProfileElementSequence:
|
|||||||
|
|
||||||
|
|
||||||
class FsNode:
|
class FsNode:
|
||||||
"""A node in the filesystem hierarchy."""
|
"""A node in the filesystem hierarchy. Each node can have a parent node and any number of children.
|
||||||
|
Each node is identified uniquely within the parent by its numeric FID and its optional human-readable
|
||||||
|
name. Each node usually is associated with an instance of the File class for the actual content of
|
||||||
|
the file. FsNode is the base class used by more specific nodes, such as FsNode{EF,DF,ADF,MF}."""
|
||||||
def __init__(self, fid: int, parent: Optional['FsNode'], file: Optional[File] = None,
|
def __init__(self, fid: int, parent: Optional['FsNode'], file: Optional[File] = None,
|
||||||
name: Optional[str] = None):
|
name: Optional[str] = None):
|
||||||
self.fid = fid
|
self.fid = fid
|
||||||
@@ -1936,7 +2064,7 @@ class FsNode:
|
|||||||
return x
|
return x
|
||||||
|
|
||||||
def walk(self, fn, **kwargs):
|
def walk(self, fn, **kwargs):
|
||||||
"""call 'fn(self, **kwargs) for the File."""
|
"""call 'fn(self, ``**kwargs``) for the File."""
|
||||||
return [fn(self, **kwargs)]
|
return [fn(self, **kwargs)]
|
||||||
|
|
||||||
class FsNodeEF(FsNode):
|
class FsNodeEF(FsNode):
|
||||||
@@ -2026,7 +2154,7 @@ class FsNodeDF(FsNode):
|
|||||||
return cur
|
return cur
|
||||||
|
|
||||||
def walk(self, fn, **kwargs):
|
def walk(self, fn, **kwargs):
|
||||||
"""call 'fn(self, **kwargs) for the DF and recursively for all children."""
|
"""call 'fn(self, ``**kwargs``) for the DF and recursively for all children."""
|
||||||
ret = super().walk(fn, **kwargs)
|
ret = super().walk(fn, **kwargs)
|
||||||
for c in self.children.values():
|
for c in self.children.values():
|
||||||
ret += c.walk(fn, **kwargs)
|
ret += c.walk(fn, **kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
"""Implementation of Personalization of eSIM profiles in SimAlliance/TCA Interoperable Profile:
|
||||||
|
Run a batch of N personalizations"""
|
||||||
|
|
||||||
|
# (C) 2025-2026 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
|
||||||
|
#
|
||||||
|
# Author: nhofmeyr@sysmocom.de
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import pprint
|
||||||
|
from typing import Generator, Union
|
||||||
|
from pySim.esim.saip.personalization import ConfigurableParameter
|
||||||
|
from pySim.esim.saip import param_source
|
||||||
|
from pySim.esim.saip import ProfileElementSequence, ProfileElementSD
|
||||||
|
from pySim.global_platform import KeyUsageQualifier
|
||||||
|
from osmocom.utils import b2h
|
||||||
|
|
||||||
|
# a list of ConfigurableParameter classes and/or ConfigurableParameter class instances
|
||||||
|
ParamList = list[Union[type[ConfigurableParameter], ConfigurableParameter]]
|
||||||
|
|
||||||
|
class BatchPersonalization:
|
||||||
|
"""Produce a series of eSIM profiles from predefined parameters.
|
||||||
|
Personalization parameters are derived from pysim.esim.saip.param_source.ParamSource.
|
||||||
|
|
||||||
|
Usage example:
|
||||||
|
|
||||||
|
der_input = open('some_file', 'rb').read()
|
||||||
|
pes = ProfileElementSequence.from_der(der_input)
|
||||||
|
p = BatchPersonalization(
|
||||||
|
n=10,
|
||||||
|
src_pes=pes,
|
||||||
|
csv_rows=get_csv_reader())
|
||||||
|
|
||||||
|
p.add_param_and_src(
|
||||||
|
personalization.Iccid(),
|
||||||
|
param_source.IncDigitSource(
|
||||||
|
num_digits=18,
|
||||||
|
first_value=123456789012340001,
|
||||||
|
last_value=123456789012340010))
|
||||||
|
|
||||||
|
# add more parameters here, using ConfigurableParameter and ParamSource subclass instances to define the profile
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# generate all 10 profiles (from n=10 above)
|
||||||
|
for result_pes in p.generate_profiles():
|
||||||
|
upp = result_pes.to_der()
|
||||||
|
store_upp(upp)
|
||||||
|
"""
|
||||||
|
|
||||||
|
class ParamAndSrc:
|
||||||
|
"""tie a ConfigurableParameter to a source of actual values"""
|
||||||
|
def __init__(self, param: ConfigurableParameter, src: param_source.ParamSource):
|
||||||
|
if isinstance(param, type):
|
||||||
|
self.param_cls = param
|
||||||
|
else:
|
||||||
|
self.param_cls = param.__class__
|
||||||
|
self.src = src
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
n: int,
|
||||||
|
src_pes: ProfileElementSequence,
|
||||||
|
params: list[ParamAndSrc]=None,
|
||||||
|
csv_rows: Generator=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
n: number of eSIM profiles to generate.
|
||||||
|
src_pes: a decoded eSIM profile as ProfileElementSequence, to serve as template. This is not modified, only
|
||||||
|
copied.
|
||||||
|
params: list of ParamAndSrc instances, defining a ConfigurableParameter and corresponding ParamSource to fill in
|
||||||
|
profile values.
|
||||||
|
csv_rows: A generator (e.g. iter(list_of_rows)) producing all CSV rows one at a time, starting with a row
|
||||||
|
containing the column headers. This is compatible with the python csv.reader. Each row gets passed to
|
||||||
|
ParamSource.get_next(), such that ParamSource implementations can access the row items. See
|
||||||
|
param_source.CsvSource.
|
||||||
|
"""
|
||||||
|
self.n = n
|
||||||
|
self.params = params or []
|
||||||
|
self.src_pes = src_pes
|
||||||
|
self.csv_rows = csv_rows
|
||||||
|
|
||||||
|
def add_param_and_src(self, param:ConfigurableParameter, src:param_source.ParamSource):
|
||||||
|
self.params.append(BatchPersonalization.ParamAndSrc(param, src))
|
||||||
|
|
||||||
|
def generate_profiles(self):
|
||||||
|
# get first row of CSV: column names
|
||||||
|
csv_columns = None
|
||||||
|
if self.csv_rows:
|
||||||
|
try:
|
||||||
|
csv_columns = next(self.csv_rows)
|
||||||
|
except StopIteration as e:
|
||||||
|
raise ValueError('the input CSV file appears to be empty') from e
|
||||||
|
|
||||||
|
for i in range(self.n):
|
||||||
|
csv_row = None
|
||||||
|
if self.csv_rows and csv_columns:
|
||||||
|
try:
|
||||||
|
csv_row_list = next(self.csv_rows)
|
||||||
|
except StopIteration as e:
|
||||||
|
raise ValueError(f'not enough rows in the input CSV for eSIM nr {i+1} of {self.n}') from e
|
||||||
|
|
||||||
|
csv_row = dict(zip(csv_columns, csv_row_list))
|
||||||
|
|
||||||
|
pes = copy.deepcopy(self.src_pes)
|
||||||
|
|
||||||
|
for p in self.params:
|
||||||
|
try:
|
||||||
|
input_value = p.src.get_next(csv_row=csv_row)
|
||||||
|
assert input_value is not None
|
||||||
|
value = p.param_cls.validate_val(input_value)
|
||||||
|
p.param_cls.apply_val(pes, value)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f'{p.param_cls.get_name()} fed by {p.src.name}: {e}') from e
|
||||||
|
|
||||||
|
pes.rebuild_mandatory_services()
|
||||||
|
|
||||||
|
yield pes
|
||||||
|
|
||||||
|
|
||||||
|
class UppAudit(dict):
|
||||||
|
"""
|
||||||
|
Key-value pairs collected from a single UPP DER or PES.
|
||||||
|
|
||||||
|
UppAudit itself is a dict, callers may use the standard python dict API to access key-value pairs read from the UPP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_der(cls, der: bytes, params: ParamList, der_size=False, additional_sd_keys=False):
|
||||||
|
"""return a dict of parameter name and set of selected parameter values found in a DER encoded profile. Note:
|
||||||
|
some ConfigurableParameter implementations return more than one key-value pair, for example, Imsi returns
|
||||||
|
both 'IMSI' and 'IMSI-ACC' parameters.
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
UppAudit.from_der(my_der, [Imsi, ])
|
||||||
|
--> {'IMSI': {'001010000000023'}, 'IMSI-ACC': {'5'}}
|
||||||
|
|
||||||
|
(where 'IMSI' == Imsi.name)
|
||||||
|
|
||||||
|
Read all parameters listed in params. params is a list of either ConfigurableParameter classes or
|
||||||
|
ConfigurableParameter class instances. This calls only classmethods, so each entry in params can either be the
|
||||||
|
class itself, or a class-instance of, a (non-abstract) ConfigurableParameter subclass.
|
||||||
|
For example, params = [Imsi, ] is equivalent to params = [Imsi(), ].
|
||||||
|
|
||||||
|
For der_size=True, also include a {'der_size':12345} entry.
|
||||||
|
|
||||||
|
For additional_sd_keys=True, output also all Security Domain KVN that there are *no* ConfigurableParameter
|
||||||
|
subclasses for. For example, SCP80 has reserved kvn 0x01..0x0f, but we offer only Scp80Kvn01, Scp80Kvn02,
|
||||||
|
Scp80Kvn03. So we would not show kvn 0x04..0x0f in an audit. additional_sd_keys=True includes audits of all SD
|
||||||
|
key KVN there may be in the UPP. This helps to spot SD keys that may already be present in a UPP template, with
|
||||||
|
unexpected / unusual kvn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# make an instance of this class
|
||||||
|
upp_audit = cls()
|
||||||
|
|
||||||
|
if der_size:
|
||||||
|
upp_audit['der_size'] = set((len(der), ))
|
||||||
|
|
||||||
|
pes = ProfileElementSequence.from_der(der)
|
||||||
|
for param in params:
|
||||||
|
try:
|
||||||
|
for valdict in param.get_values_from_pes(pes):
|
||||||
|
upp_audit.add_values(valdict)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f'Error during audit for parameter {param}: {e}') from e
|
||||||
|
|
||||||
|
if not additional_sd_keys:
|
||||||
|
return upp_audit
|
||||||
|
|
||||||
|
# additional_sd_keys
|
||||||
|
for pe in pes.pe_list:
|
||||||
|
if pe.type != 'securityDomain':
|
||||||
|
continue
|
||||||
|
assert isinstance(pe, ProfileElementSD)
|
||||||
|
|
||||||
|
for key in pe.keys:
|
||||||
|
audit_key = f'SdKey_KVN{key.key_version_number:02x}_ID{key.key_identifier:02x}'
|
||||||
|
kuq_bin = KeyUsageQualifier.build(key.key_usage_qualifier).hex()
|
||||||
|
audit_val = f'{key.key_components=!r} key_usage_qualifier=0x{kuq_bin}={key.key_usage_qualifier!r}'
|
||||||
|
upp_audit.add_values({audit_key: audit_val})
|
||||||
|
|
||||||
|
return upp_audit
|
||||||
|
|
||||||
|
def get_single_val(self, key, allow_absent=False, absent_val=None):
|
||||||
|
"""
|
||||||
|
Return the audit's value for the given audit key (like 'IMSI' or 'IMSI-ACC').
|
||||||
|
Any kind of value may occur multiple times in a profile. When all of these agree to the same unambiguous value,
|
||||||
|
return that value. When they do not agree, raise a ValueError.
|
||||||
|
"""
|
||||||
|
# key should be a string, but if someone passes a ConfigurableParameter, just use its default name
|
||||||
|
if ConfigurableParameter.is_super_of(key):
|
||||||
|
key = key.get_name()
|
||||||
|
|
||||||
|
assert isinstance(key, str)
|
||||||
|
v = self.get(key)
|
||||||
|
if v is None and allow_absent:
|
||||||
|
return absent_val
|
||||||
|
if not isinstance(v, set):
|
||||||
|
raise ValueError(f'audit value should be a set(), got {v!r}')
|
||||||
|
if len(v) != 1:
|
||||||
|
raise ValueError(f'expected a single value for {key}, got {v!r}')
|
||||||
|
v = tuple(v)[0]
|
||||||
|
return v
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def audit_val_to_str(v):
|
||||||
|
"""
|
||||||
|
Usually, we want to see a single value in an audit. Still, to be able to collect multiple ambiguous values,
|
||||||
|
audit values are always python sets. Turn it into a nice string representation: only the value when it is
|
||||||
|
unambiguous, otherwise a list of the ambiguous values.
|
||||||
|
A value may also be completely absent, then return 'not present'.
|
||||||
|
"""
|
||||||
|
def try_single_val(w):
|
||||||
|
'change single-entry sets to just the single value'
|
||||||
|
if isinstance(w, set):
|
||||||
|
if len(w) == 1:
|
||||||
|
return tuple(w)[0]
|
||||||
|
if len(w) == 0:
|
||||||
|
return None
|
||||||
|
return w
|
||||||
|
|
||||||
|
v = try_single_val(v)
|
||||||
|
if isinstance(v, bytes):
|
||||||
|
v = b2h(v)
|
||||||
|
if v is None:
|
||||||
|
return 'not present'
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
def get_val_str(self, key):
|
||||||
|
"""Return a string of the value stored for the given key"""
|
||||||
|
return UppAudit.audit_val_to_str(self.get(key))
|
||||||
|
|
||||||
|
def add_values(self, src:dict):
|
||||||
|
"""Merge a plain dict of values into self, which is a dict of sets.
|
||||||
|
For example from
|
||||||
|
self == { 'a': {123} }
|
||||||
|
and
|
||||||
|
src == { 'a': 456, 'b': 789 }
|
||||||
|
then after this function call:
|
||||||
|
self == { 'a': {123, 456}, 'b': {789} }
|
||||||
|
"""
|
||||||
|
assert isinstance(src, dict)
|
||||||
|
for key, srcval in src.items():
|
||||||
|
dstvalset = self.get(key)
|
||||||
|
if dstvalset is None:
|
||||||
|
dstvalset = set()
|
||||||
|
self[key] = dstvalset
|
||||||
|
dstvalset.add(srcval)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '\n'.join(f'{key}: {self.get_val_str(key)}' for key in sorted(self.keys()))
|
||||||
|
|
||||||
|
class BatchAudit(list):
|
||||||
|
"""
|
||||||
|
Collect UppAudit instances for a batch of UPP, for example from a personalization.BatchPersonalization.
|
||||||
|
Produce an output CSV.
|
||||||
|
|
||||||
|
Usage example:
|
||||||
|
|
||||||
|
ba = BatchAudit(params=(personalization.Iccid, ))
|
||||||
|
for upp_der in upps:
|
||||||
|
ba.add_audit(upp_der)
|
||||||
|
print(ba.summarize())
|
||||||
|
|
||||||
|
with open('output.csv', 'wb') as csv_data:
|
||||||
|
csv_str = io.TextIOWrapper(csv_data, 'utf-8', newline='')
|
||||||
|
csv.writer(csv_str).writerows( ba.to_csv_rows() )
|
||||||
|
csv_str.flush()
|
||||||
|
|
||||||
|
BatchAudit itself is a list, callers may use the standard python list API to access the UppAudit instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, params: ParamList):
|
||||||
|
assert params
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
def add_audit(self, upp_der:bytes):
|
||||||
|
audit = UppAudit.from_der(upp_der, self.params)
|
||||||
|
self.append(audit)
|
||||||
|
return audit
|
||||||
|
|
||||||
|
def summarize(self):
|
||||||
|
batch_audit = UppAudit()
|
||||||
|
|
||||||
|
audits = self
|
||||||
|
|
||||||
|
if len(audits) > 2:
|
||||||
|
val_sep = ', ..., '
|
||||||
|
else:
|
||||||
|
val_sep = ', '
|
||||||
|
|
||||||
|
first_audit = None
|
||||||
|
last_audit = None
|
||||||
|
if len(audits) >= 1:
|
||||||
|
first_audit = audits[0]
|
||||||
|
if len(audits) >= 2:
|
||||||
|
last_audit = audits[-1]
|
||||||
|
|
||||||
|
if first_audit:
|
||||||
|
if last_audit:
|
||||||
|
for key in first_audit.keys():
|
||||||
|
first_val = first_audit.get_val_str(key)
|
||||||
|
last_val = last_audit.get_val_str(key)
|
||||||
|
|
||||||
|
if first_val == last_val:
|
||||||
|
val = first_val
|
||||||
|
else:
|
||||||
|
val_sep_with_newline = f"{val_sep.rstrip()}\n{' ' * (len(key) + 2)}"
|
||||||
|
val = val_sep_with_newline.join((first_val, last_val))
|
||||||
|
batch_audit[key] = val
|
||||||
|
else:
|
||||||
|
batch_audit.update(first_audit)
|
||||||
|
|
||||||
|
return batch_audit
|
||||||
|
|
||||||
|
def to_csv_rows(self, headers=True, sort_key=None):
|
||||||
|
"""generator that yields all audits' values as rows, useful feed to a csv.writer."""
|
||||||
|
columns = set()
|
||||||
|
for audit in self:
|
||||||
|
columns.update(audit.keys())
|
||||||
|
|
||||||
|
columns = tuple(sorted(columns, key=sort_key))
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
yield columns
|
||||||
|
|
||||||
|
for audit in self:
|
||||||
|
yield (audit.get_single_val(col, allow_absent=True, absent_val="") for col in columns)
|
||||||
|
|
||||||
|
def esim_profile_introspect(upp):
|
||||||
|
pes = ProfileElementSequence.from_der(upp.read())
|
||||||
|
d = {}
|
||||||
|
d['upp'] = repr(pes)
|
||||||
|
|
||||||
|
def show_bytes_as_hexdump(item):
|
||||||
|
if isinstance(item, bytes):
|
||||||
|
return b2h(item)
|
||||||
|
if isinstance(item, list):
|
||||||
|
return list(show_bytes_as_hexdump(i) for i in item)
|
||||||
|
if isinstance(item, tuple):
|
||||||
|
return tuple(show_bytes_as_hexdump(i) for i in item)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
d = {}
|
||||||
|
for k, v in item.items():
|
||||||
|
d[k] = show_bytes_as_hexdump(v)
|
||||||
|
return d
|
||||||
|
return item
|
||||||
|
|
||||||
|
l = list((pe.type, show_bytes_as_hexdump(pe.decoded)) for pe in pes)
|
||||||
|
d['pp'] = pprint.pformat(l, width=120)
|
||||||
|
return d
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Implementation of SimAlliance/TCA Interoperable Profile handling: parameter sources for batch personalization.
|
||||||
|
#
|
||||||
|
# (C) 2025 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
|
||||||
|
#
|
||||||
|
# Author: nhofmeyr@sysmocom.de
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import re
|
||||||
|
from osmocom.utils import b2h
|
||||||
|
|
||||||
|
class ParamSourceExn(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ParamSourceExhaustedExn(ParamSourceExn):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ParamSourceUndefinedExn(ParamSourceExn):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ParamSource:
|
||||||
|
"""abstract parameter source. For usage, see personalization.BatchPersonalization."""
|
||||||
|
|
||||||
|
# This name should be short but descriptive, useful for a user interface, like 'random decimal digits'.
|
||||||
|
name = "none"
|
||||||
|
numeric_base = None # or 10 or 16
|
||||||
|
|
||||||
|
def __init__(self, input_str:str):
|
||||||
|
"""Subclasses should call super().__init__(input_str) before evaluating self.input_str. Each subclass __init__()
|
||||||
|
may in turn manipulate self.input_str to apply expansions or decodings."""
|
||||||
|
self.input_str = input_str
|
||||||
|
|
||||||
|
def get_next(self, csv_row:dict=None):
|
||||||
|
"""Subclasses implement this: return the next value from the parameter source.
|
||||||
|
When there are no more values from the source, raise a ParamSourceExhaustedExn.
|
||||||
|
This default implementation is an empty source."""
|
||||||
|
raise ParamSourceExhaustedExn()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_str(cls, input_str:str):
|
||||||
|
"""compatibility with earlier version of ParamSource. Just use the constructor."""
|
||||||
|
return cls(input_str)
|
||||||
|
|
||||||
|
class ConstantSource(ParamSource):
|
||||||
|
"""one value for all"""
|
||||||
|
name = "constant"
|
||||||
|
|
||||||
|
def get_next(self, csv_row:dict=None):
|
||||||
|
return self.input_str
|
||||||
|
|
||||||
|
class InputExpandingParamSource(ParamSource):
|
||||||
|
|
||||||
|
def __init__(self, input_str:str):
|
||||||
|
super().__init__(input_str)
|
||||||
|
self.input_str = self.expand_input_str(self.input_str)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def expand_input_str(cls, input_str:str):
|
||||||
|
# user convenience syntax '0*32' becomes '00000000000000000000000000000000'
|
||||||
|
if "*" not in input_str:
|
||||||
|
return input_str
|
||||||
|
# re: "XX * 123" with optional spaces
|
||||||
|
tokens = re.split(r"([^ \t]+)[ \t]*\*[ \t]*([0-9]+)", input_str)
|
||||||
|
if len(tokens) < 3:
|
||||||
|
return input_str
|
||||||
|
parts = []
|
||||||
|
for unchanged, snippet, repeat_str in zip(tokens[0::3], tokens[1::3], tokens[2::3]):
|
||||||
|
parts.append(unchanged)
|
||||||
|
repeat = int(repeat_str)
|
||||||
|
parts.append(snippet * repeat)
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
class DecimalRangeSource(InputExpandingParamSource):
|
||||||
|
"""abstract: decimal numbers with a value range"""
|
||||||
|
|
||||||
|
numeric_base = 10
|
||||||
|
|
||||||
|
def __init__(self, input_str:str=None, num_digits:int=None, first_value:int=None, last_value:int=None):
|
||||||
|
"""Constructor to set up values from a (user entered) string: DecimalRangeSource(input_str).
|
||||||
|
Constructor to set up values directly: DecimalRangeSource(num_digits=3, first_value=123, last_value=456)
|
||||||
|
|
||||||
|
num_digits produces leading zeros when first_value..last_value are shorter.
|
||||||
|
"""
|
||||||
|
assert ((input_str is not None and (num_digits, first_value, last_value) == (None, None, None))
|
||||||
|
or (input_str is None and None not in (num_digits, first_value, last_value)))
|
||||||
|
|
||||||
|
if input_str is not None:
|
||||||
|
super().__init__(input_str)
|
||||||
|
|
||||||
|
input_str = self.input_str
|
||||||
|
|
||||||
|
if ".." in input_str:
|
||||||
|
first_str, last_str = input_str.split('..')
|
||||||
|
first_str = first_str.strip()
|
||||||
|
last_str = last_str.strip()
|
||||||
|
else:
|
||||||
|
first_str = input_str.strip()
|
||||||
|
last_str = None
|
||||||
|
|
||||||
|
num_digits = len(first_str)
|
||||||
|
first_value = int(first_str)
|
||||||
|
last_value = int(last_str if last_str is not None else "9" * num_digits)
|
||||||
|
|
||||||
|
assert num_digits > 0
|
||||||
|
assert first_value <= last_value
|
||||||
|
self.num_digits = num_digits
|
||||||
|
self.first_value = first_value
|
||||||
|
self.last_value = last_value
|
||||||
|
|
||||||
|
def val_to_digit(self, val:int):
|
||||||
|
return "%0*d" % (self.num_digits, val) # pylint: disable=consider-using-f-string
|
||||||
|
|
||||||
|
class RandomSourceMixin:
|
||||||
|
random_impl = secrets.SystemRandom()
|
||||||
|
|
||||||
|
class RandomDigitSource(DecimalRangeSource, RandomSourceMixin):
|
||||||
|
"""return a different sequence of random decimal digits each"""
|
||||||
|
name = "random decimal digits"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.used_keys = set()
|
||||||
|
|
||||||
|
def get_next(self, csv_row:dict=None):
|
||||||
|
# try to generate random digits that are always different from previously produced random digits
|
||||||
|
for _ in range(10):
|
||||||
|
val = self.random_impl.randint(self.first_value, self.last_value)
|
||||||
|
if val not in self.used_keys:
|
||||||
|
break
|
||||||
|
self.used_keys.add(val)
|
||||||
|
return self.val_to_digit(val)
|
||||||
|
|
||||||
|
class RandomHexDigitSource(InputExpandingParamSource, RandomSourceMixin):
|
||||||
|
"""return a different sequence of random hexadecimal digits each"""
|
||||||
|
name = "random hexadecimal digits"
|
||||||
|
numeric_base = 16
|
||||||
|
def __init__(self, input_str:str):
|
||||||
|
super().__init__(input_str)
|
||||||
|
input_str = self.input_str
|
||||||
|
|
||||||
|
num_digits = len(input_str.strip())
|
||||||
|
if num_digits < 1:
|
||||||
|
raise ValueError("zero number of digits")
|
||||||
|
# hex digits always come in two
|
||||||
|
if (num_digits & 1) != 0:
|
||||||
|
raise ValueError(f"hexadecimal value should have even number of digits, not {num_digits}")
|
||||||
|
self.num_digits = num_digits
|
||||||
|
self.used_keys = set()
|
||||||
|
|
||||||
|
def get_next(self, csv_row:dict=None):
|
||||||
|
# try to generate random bytes that are always different from previously produced random bytes
|
||||||
|
for _ in range(10):
|
||||||
|
val = self.random_impl.randbytes(self.num_digits // 2)
|
||||||
|
if val not in self.used_keys:
|
||||||
|
break
|
||||||
|
self.used_keys.add(val)
|
||||||
|
|
||||||
|
return b2h(val)
|
||||||
|
|
||||||
|
class IncDigitSource(DecimalRangeSource):
|
||||||
|
"""incrementing sequence of digits"""
|
||||||
|
name = "incrementing decimal digits"
|
||||||
|
|
||||||
|
def __init__(self, input_str:str=None, num_digits:int=None, first_value:int=None, last_value:int=None):
|
||||||
|
"""input_str: the range of values to iterate. Format: 'FIRST..LAST' (e.g. '0001..9999') or
|
||||||
|
just 'FIRST' (iterates to the maximum value for the given digit width). Leading zeros in
|
||||||
|
FIRST determine the digit width and are preserved in returned values."""
|
||||||
|
super().__init__(input_str, num_digits, first_value, last_value)
|
||||||
|
self.next_val = None
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Restart from the first value of the defined range passed to __init__()."""
|
||||||
|
self.next_val = self.first_value
|
||||||
|
|
||||||
|
def get_next(self, csv_row:dict=None):
|
||||||
|
val = self.next_val
|
||||||
|
if val is None:
|
||||||
|
raise ParamSourceExhaustedExn()
|
||||||
|
|
||||||
|
returnval = self.val_to_digit(val)
|
||||||
|
|
||||||
|
val += 1
|
||||||
|
if val > self.last_value:
|
||||||
|
self.next_val = None
|
||||||
|
else:
|
||||||
|
self.next_val = val
|
||||||
|
|
||||||
|
return returnval
|
||||||
|
|
||||||
|
class CsvSource(ParamSource):
|
||||||
|
"""apply a column from a CSV row, as passed in to ParamSource.get_next(csv_row)"""
|
||||||
|
name = "from CSV"
|
||||||
|
|
||||||
|
def __init__(self, input_str:str):
|
||||||
|
"""input_str: the CSV column name to read values from.
|
||||||
|
The caller passes the current CSV row to get_next(), from which CsvSource picks the column matching
|
||||||
|
this name."""
|
||||||
|
super().__init__(input_str)
|
||||||
|
self.csv_column = self.input_str
|
||||||
|
|
||||||
|
def get_next(self, csv_row:dict=None):
|
||||||
|
val = None
|
||||||
|
if csv_row:
|
||||||
|
val = csv_row.get(self.csv_column)
|
||||||
|
if val is None:
|
||||||
|
raise ParamSourceUndefinedExn(f"no value for CSV column {self.csv_column!r}")
|
||||||
|
return val
|
||||||
+1069
-207
File diff suppressed because it is too large
Load Diff
@@ -673,7 +673,7 @@ class FilesUsimDf5GS(ProfileTemplate):
|
|||||||
FileTemplate(0x4f06, 'EF.UAC_AIC', 'TR', None, 4, 2, 0x06, None, True, ass_serv=[126]),
|
FileTemplate(0x4f06, 'EF.UAC_AIC', 'TR', None, 4, 2, 0x06, None, True, ass_serv=[126]),
|
||||||
FileTemplate(0x4f07, 'EF.SUCI_Calc_Info', 'TR', None, None, 2, 0x07, 'FF...FF', False, ass_serv=[124]),
|
FileTemplate(0x4f07, 'EF.SUCI_Calc_Info', 'TR', None, None, 2, 0x07, 'FF...FF', False, ass_serv=[124]),
|
||||||
FileTemplate(0x4f08, 'EF.OPL5G', 'LF', None, 10, 10, 0x08, 'FF...FF', False, ['nb_rec'], ass_serv=[129]),
|
FileTemplate(0x4f08, 'EF.OPL5G', 'LF', None, 10, 10, 0x08, 'FF...FF', False, ['nb_rec'], ass_serv=[129]),
|
||||||
FileTemplate(0x4f09, 'EF.SUPI_NAI', 'TR', None, None, 2, 0x09, None, True, ['size'], ass_serv=[130]),
|
FileTemplate(0x4f09, 'EF.SUPI_NAI', 'TR', None, None, 2, 0x09, None, True, ['size'], ass_serv=[130], pe_name='ef-supinai'),
|
||||||
FileTemplate(0x4f0a, 'EF.Routing_Indicator', 'TR', None, 4, 2, 0x0a, 'F0FFFFFF', False, ass_serv=[124]),
|
FileTemplate(0x4f0a, 'EF.Routing_Indicator', 'TR', None, 4, 2, 0x0a, 'F0FFFFFF', False, ass_serv=[124]),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -818,7 +818,7 @@ class FilesIsimOptional(ProfileTemplate):
|
|||||||
base_path = Path('ADF.ISIM')
|
base_path = Path('ADF.ISIM')
|
||||||
extends = FilesIsimMandatory
|
extends = FilesIsimMandatory
|
||||||
files = [
|
files = [
|
||||||
FileTemplate(0x6f09, 'EF.P-CSCF', 'LF', 1, None, 2, None, None, True, ['size'], ass_serv=[1,5]),
|
FileTemplate(0x6f09, 'EF.P-CSCF', 'LF', 1, None, 2, None, None, True, ['size'], ass_serv=[1,5], pe_name='ef-pcscf'),
|
||||||
FileTemplate(0x6f3c, 'EF.SMS', 'LF', 10, 176, 5, None, '00FF...FF', False, ass_serv=[6,8]),
|
FileTemplate(0x6f3c, 'EF.SMS', 'LF', 10, 176, 5, None, '00FF...FF', False, ass_serv=[6,8]),
|
||||||
FileTemplate(0x6f42, 'EF.SMSP', 'LF', 1, 38, 5, None, 'FF...FF', False, ass_serv=[8]),
|
FileTemplate(0x6f42, 'EF.SMSP', 'LF', 1, 38, 5, None, 'FF...FF', False, ass_serv=[8]),
|
||||||
FileTemplate(0x6f43, 'EF.SMSS', 'TR', None, 2, 5, None, 'FFFF', False, ass_serv=[6,8]),
|
FileTemplate(0x6f43, 'EF.SMSS', 'TR', None, 2, 5, None, 'FFFF', False, ass_serv=[6,8]),
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ class CheckBasicStructure(ProfileConstraintChecker):
|
|||||||
if 'profile-a-p256' in m_svcs and not ('usim' in m_svcs or 'isim' in m_svcs):
|
if 'profile-a-p256' in m_svcs and not ('usim' in m_svcs or 'isim' in m_svcs):
|
||||||
raise ProfileError('profile-a-p256 mandatory, but no usim or isim')
|
raise ProfileError('profile-a-p256 mandatory, but no usim or isim')
|
||||||
|
|
||||||
|
def check_mandatory_services_aka(self, pes: ProfileElementSequence):
|
||||||
|
"""Ensure that no unnecessary authentication related services are marked as mandatory but not
|
||||||
|
actually used within the profile"""
|
||||||
|
m_svcs = pes.get_pe_for_type('header').decoded['eUICC-Mandatory-services']
|
||||||
|
# list of tuples (algo_id, key_len_in_octets) for all the akaParameters in the PE Sequence
|
||||||
|
algo_id_klen = [(x.decoded['algoConfiguration'][1]['algorithmID'],
|
||||||
|
len(x.decoded['algoConfiguration'][1]['key'])) for x in pes.get_pes_for_type('akaParameter')]
|
||||||
|
# just a plain list of algorithm IDs in akaParameters
|
||||||
|
algorithm_ids = [x[0] for x in algo_id_klen]
|
||||||
|
if 'milenage' in m_svcs and not 1 in algorithm_ids:
|
||||||
|
raise ProfileError('milenage mandatory, but no related algorithm_id in akaParameter')
|
||||||
|
if 'tuak128' in m_svcs and not (2, 128/8) in algo_id_klen:
|
||||||
|
raise ProfileError('tuak128 mandatory, but no related algorithm_id in akaParameter')
|
||||||
|
if 'cave' in m_svcs and not pes.get_pe_for_type('cdmaParameter'):
|
||||||
|
raise ProfileError('cave mandatory, but no related cdmaParameter')
|
||||||
|
if 'tuak256' in m_svcs and (2, 256/8) in algo_id_klen:
|
||||||
|
raise ProfileError('tuak256 mandatory, but no related algorithm_id in akaParameter')
|
||||||
|
if 'usim-test-algorithm' in m_svcs and not 3 in algorithm_ids:
|
||||||
|
raise ProfileError('usim-test-algorithm mandatory, but no related algorithm_id in akaParameter')
|
||||||
|
|
||||||
def check_identification_unique(self, pes: ProfileElementSequence):
|
def check_identification_unique(self, pes: ProfileElementSequence):
|
||||||
"""Ensure that each PE has a unique identification value."""
|
"""Ensure that each PE has a unique identification value."""
|
||||||
id_list = [pe.header['identification'] for pe in pes.pe_list if pe.header]
|
id_list = [pe.header['identification'] for pe in pes.pe_list if pe.header]
|
||||||
|
|||||||
+42
-4
@@ -181,7 +181,7 @@ class SeqNumber(BER_TLV_IE, tag=0x80):
|
|||||||
class NotificationAddress(BER_TLV_IE, tag=0x0c):
|
class NotificationAddress(BER_TLV_IE, tag=0x0c):
|
||||||
_construct = Utf8Adapter(GreedyBytes)
|
_construct = Utf8Adapter(GreedyBytes)
|
||||||
class Iccid(BER_TLV_IE, tag=0x5a):
|
class Iccid(BER_TLV_IE, tag=0x5a):
|
||||||
_construct = BcdAdapter(GreedyBytes)
|
_construct = PaddedBcdAdapter(GreedyBytes)
|
||||||
class NotificationMetadata(BER_TLV_IE, tag=0xbf2f, nested=[SeqNumber, ProfileMgmtOperation,
|
class NotificationMetadata(BER_TLV_IE, tag=0xbf2f, nested=[SeqNumber, ProfileMgmtOperation,
|
||||||
NotificationAddress, Iccid]):
|
NotificationAddress, Iccid]):
|
||||||
pass
|
pass
|
||||||
@@ -226,9 +226,28 @@ class Icon(BER_TLV_IE, tag=0x94):
|
|||||||
_construct = GreedyBytes
|
_construct = GreedyBytes
|
||||||
class ProfileClass(BER_TLV_IE, tag=0x95):
|
class ProfileClass(BER_TLV_IE, tag=0x95):
|
||||||
_construct = Enum(Int8ub, test=0, provisioning=1, operational=2)
|
_construct = Enum(Int8ub, test=0, provisioning=1, operational=2)
|
||||||
|
class ProfilePolicyRules(BER_TLV_IE, tag=0x99):
|
||||||
|
_construct = GreedyBytes
|
||||||
|
class NotificationConfigurationInfo(BER_TLV_IE, tag=0xb6):
|
||||||
|
_construct = GreedyBytes
|
||||||
|
|
||||||
|
# ProfileOwner
|
||||||
|
class ProfileOwnerPLMN(BER_TLV_IE, tag=0x80):
|
||||||
|
_construct = PlmnAdapter(Bytes(3))
|
||||||
|
class ProfileOwnerGID1(BER_TLV_IE, tag=0x81):
|
||||||
|
_construct = GreedyBytes
|
||||||
|
class ProfileOwnerGID2(BER_TLV_IE, tag=0x82):
|
||||||
|
_construct = GreedyBytes
|
||||||
|
class ProfileOwner(BER_TLV_IE, tag=0xb7, nested=[ProfileOwnerPLMN, ProfileOwnerGID1, ProfileOwnerGID2]):
|
||||||
|
_construct = GreedyBytes
|
||||||
|
|
||||||
|
class SMDPPProprietaryData(BER_TLV_IE, tag=0xb8):
|
||||||
|
_construct = GreedyBytes
|
||||||
|
|
||||||
class ProfileInfo(BER_TLV_IE, tag=0xe3, nested=[Iccid, IsdpAid, ProfileState, ProfileNickname,
|
class ProfileInfo(BER_TLV_IE, tag=0xe3, nested=[Iccid, IsdpAid, ProfileState, ProfileNickname,
|
||||||
ServiceProviderName, ProfileName, IconType, Icon,
|
ServiceProviderName, ProfileName, IconType, Icon,
|
||||||
ProfileClass]): # FIXME: more IEs
|
ProfileClass, ProfilePolicyRules, NotificationConfigurationInfo,
|
||||||
|
ProfileOwner, SMDPPProprietaryData]):
|
||||||
pass
|
pass
|
||||||
class ProfileInfoSeq(BER_TLV_IE, tag=0xa0, nested=[ProfileInfo]):
|
class ProfileInfoSeq(BER_TLV_IE, tag=0xa0, nested=[ProfileInfo]):
|
||||||
pass
|
pass
|
||||||
@@ -444,9 +463,28 @@ class CardApplicationISDR(pySim.global_platform.CardApplicationSD):
|
|||||||
d = rn.to_dict()
|
d = rn.to_dict()
|
||||||
self._cmd.poutput_json(flatten_dict_lists(d['notification_sent_resp']))
|
self._cmd.poutput_json(flatten_dict_lists(d['notification_sent_resp']))
|
||||||
|
|
||||||
def do_get_profiles_info(self, _opts):
|
get_profiles_info_parser = argparse.ArgumentParser()
|
||||||
|
get_profiles_info_parser.add_argument('--all', action='store_true', help='Retrieve all known tags of a profile')
|
||||||
|
|
||||||
|
@cmd2.with_argparser(get_profiles_info_parser)
|
||||||
|
def do_get_profiles_info(self, opts):
|
||||||
"""Perform an ES10c GetProfilesInfo function."""
|
"""Perform an ES10c GetProfilesInfo function."""
|
||||||
pi = CardApplicationISDR.store_data_tlv(self._cmd.lchan.scc, ProfileInfoListReq(), ProfileInfoListResp)
|
if opts.all:
|
||||||
|
tags = [nest.tag for nest in ProfileInfo.nested_collection_cls().nested]
|
||||||
|
u8tags = []
|
||||||
|
# TODO: rework TagList to support 2 byte tags to not filter it into u8 tags
|
||||||
|
for tag in tags:
|
||||||
|
if tag <= 255:
|
||||||
|
u8tags.append(tag)
|
||||||
|
elif tag <= 65535:
|
||||||
|
u8tags.append(tag >> 8)
|
||||||
|
u8tags.append(tag & 0xff)
|
||||||
|
# Ignoring 3 byte tags
|
||||||
|
req = ProfileInfoListReq(children=[TagList(decoded=u8tags)])
|
||||||
|
else:
|
||||||
|
req = ProfileInfoListReq()
|
||||||
|
|
||||||
|
pi = CardApplicationISDR.store_data_tlv(self._cmd.lchan.scc, req, ProfileInfoListResp)
|
||||||
d = pi.to_dict()
|
d = pi.to_dict()
|
||||||
self._cmd.poutput_json(flatten_dict_lists(d['profile_info_list_resp']))
|
self._cmd.poutput_json(flatten_dict_lists(d['profile_info_list_resp']))
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -38,15 +38,16 @@ class SwMatchError(Exception):
|
|||||||
"""Raised when an operation specifies an expected SW but the actual SW from
|
"""Raised when an operation specifies an expected SW but the actual SW from
|
||||||
the card doesn't match."""
|
the card doesn't match."""
|
||||||
|
|
||||||
def __init__(self, sw_actual: str, sw_expected: str, rs=None):
|
def __init__(self, sw_actual: str, sw_expected, rs=None):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
sw_actual : the SW we actually received from the card (4 hex digits)
|
sw_actual : the SW we actually received from the card (4 hex digits)
|
||||||
sw_expected : the SW we expected to receive from the card (4 hex digits)
|
sw_expected : the SW we expected to receive from the card (4 hex digits),
|
||||||
|
or a list of acceptable ones
|
||||||
rs : interpreter class to convert SW to string
|
rs : interpreter class to convert SW to string
|
||||||
"""
|
"""
|
||||||
self.sw_actual = sw_actual
|
self.sw_actual = sw_actual
|
||||||
self.sw_expected = sw_expected
|
self.sw_expected = '/'.join(sw_expected) if isinstance(sw_expected, (list, tuple)) else sw_expected
|
||||||
self.rs = rs
|
self.rs = rs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+89
-20
@@ -30,6 +30,7 @@ import tempfile
|
|||||||
import json
|
import json
|
||||||
import abc
|
import abc
|
||||||
import inspect
|
import inspect
|
||||||
|
import os
|
||||||
|
|
||||||
import cmd2
|
import cmd2
|
||||||
from cmd2 import CommandSet, with_default_category
|
from cmd2 import CommandSet, with_default_category
|
||||||
@@ -43,6 +44,7 @@ from pySim.utils import sw_match, decomposeATR
|
|||||||
from pySim.jsonpath import js_path_modify
|
from pySim.jsonpath import js_path_modify
|
||||||
from pySim.commands import SimCardCommands
|
from pySim.commands import SimCardCommands
|
||||||
from pySim.exceptions import SwMatchError
|
from pySim.exceptions import SwMatchError
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
# int: a single service is associated with this file
|
# int: a single service is associated with this file
|
||||||
# list: any of the listed services requires this file
|
# list: any of the listed services requires this file
|
||||||
@@ -51,6 +53,8 @@ CardFileService = Union[int, List[int], Tuple[int, ...]]
|
|||||||
|
|
||||||
Size = Tuple[int, Optional[int]]
|
Size = Tuple[int, Optional[int]]
|
||||||
|
|
||||||
|
log = PySimLogger.get(__name__)
|
||||||
|
|
||||||
class CardFile:
|
class CardFile:
|
||||||
"""Base class for all objects in the smart card filesystem.
|
"""Base class for all objects in the smart card filesystem.
|
||||||
Serve as a common ancestor to all other file types; rarely used directly.
|
Serve as a common ancestor to all other file types; rarely used directly.
|
||||||
@@ -552,6 +556,85 @@ class CardADF(CardDF):
|
|||||||
return lchan.selected_file.application.export(as_json, lchan)
|
return lchan.selected_file.application.export(as_json, lchan)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonEditor:
|
||||||
|
"""Context manager for editing a JSON-encoded EF value in an external editor.
|
||||||
|
|
||||||
|
Writes the current JSON value (plus encode/decode examples as //-comments)
|
||||||
|
to a temporary file, opens the user's editor, then reads the result back
|
||||||
|
(stripping comment lines) and returns it as the context variable::
|
||||||
|
|
||||||
|
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
|
||||||
|
if edited_json != orig_json:
|
||||||
|
...write back...
|
||||||
|
"""
|
||||||
|
def __init__(self, cmd, orig_json, ef):
|
||||||
|
self._cmd = cmd
|
||||||
|
self._orig_json = orig_json
|
||||||
|
self._ef = ef
|
||||||
|
self._file = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_comments(text: str) -> str:
|
||||||
|
"""Strip //-comment lines from text before JSON parsing."""
|
||||||
|
# TODO: also strip inline comments?
|
||||||
|
return '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('//'))
|
||||||
|
|
||||||
|
def _append_examples_as_comments(self, text_file) -> None:
|
||||||
|
"""Append encode/decode test vectors as //-comment lines to an open file.
|
||||||
|
The examples are taken from _test_de_encode and _test_decode class
|
||||||
|
attributes (same source as the auto-generated filesystem documentation).
|
||||||
|
The comment block is intentionally ignored on read-back by _strip_comments."""
|
||||||
|
vectors = []
|
||||||
|
for attr in ('_test_de_encode', '_test_decode'):
|
||||||
|
v = getattr(type(self._ef), attr, None)
|
||||||
|
if v:
|
||||||
|
vectors.extend(v)
|
||||||
|
if not vectors:
|
||||||
|
return
|
||||||
|
ef = self._ef
|
||||||
|
parts = [ef.fully_qualified_path_str()]
|
||||||
|
if ef.fid:
|
||||||
|
parts.append(f'({ef.fid.upper()})')
|
||||||
|
if ef.desc:
|
||||||
|
parts.append(f'- {ef.desc}')
|
||||||
|
text_file.write(f'\n\n// {" ".join(parts)}\n')
|
||||||
|
text_file.write('// Examples (ignored on save):\n')
|
||||||
|
for t in vectors:
|
||||||
|
if len(t) >= 3:
|
||||||
|
encoded, record_nr, decoded = t[0], t[1], t[2]
|
||||||
|
text_file.write(f'// record {record_nr}: {encoded}\n')
|
||||||
|
else:
|
||||||
|
encoded, decoded = t[0], t[1]
|
||||||
|
text_file.write(f'// file: {encoded}\n')
|
||||||
|
for line in json.dumps(decoded, indent=4, cls=JsonEncoder).splitlines():
|
||||||
|
text_file.write(f'// {line}\n')
|
||||||
|
|
||||||
|
def __enter__(self) -> object:
|
||||||
|
"""Write JSON + examples to a temp file, run the editor, return parsed result.
|
||||||
|
|
||||||
|
On JSONDecodeError the user is offered the option to re-open the file
|
||||||
|
and fix the mistake interactively. The temp file is removed by __exit__()
|
||||||
|
on success, or when the user declines to retry."""
|
||||||
|
self._file = tempfile.NamedTemporaryFile(prefix='pysim_', suffix='.json',
|
||||||
|
mode='w', delete=False)
|
||||||
|
json.dump(self._orig_json, self._file, indent=4, cls=JsonEncoder)
|
||||||
|
self._append_examples_as_comments(self._file)
|
||||||
|
self._file.close()
|
||||||
|
while True:
|
||||||
|
self._cmd.run_editor(self._file.name)
|
||||||
|
try:
|
||||||
|
with open(self._file.name, 'r') as f:
|
||||||
|
return json.loads(self._strip_comments(f.read()))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
self._cmd.perror(f'Invalid JSON: {e}')
|
||||||
|
answer = self._cmd.read_input('Re-open file for editing? [y]es/[n]o: ')
|
||||||
|
if answer not in ('y', 'yes'):
|
||||||
|
return self._orig_json
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
os.unlink(self._file.name)
|
||||||
|
|
||||||
|
|
||||||
class CardEF(CardFile):
|
class CardEF(CardFile):
|
||||||
"""EF (Entry File) in the smart card filesystem"""
|
"""EF (Entry File) in the smart card filesystem"""
|
||||||
|
|
||||||
@@ -657,15 +740,8 @@ class TransparentEF(CardEF):
|
|||||||
def do_edit_binary_decoded(self, _opts):
|
def do_edit_binary_decoded(self, _opts):
|
||||||
"""Edit the JSON representation of the EF contents in an editor."""
|
"""Edit the JSON representation of the EF contents in an editor."""
|
||||||
(orig_json, _sw) = self._cmd.lchan.read_binary_dec()
|
(orig_json, _sw) = self._cmd.lchan.read_binary_dec()
|
||||||
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname:
|
ef = self._cmd.lchan.selected_file
|
||||||
filename = '%s/file' % dirname
|
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
|
||||||
# write existing data as JSON to file
|
|
||||||
with open(filename, 'w') as text_file:
|
|
||||||
json.dump(orig_json, text_file, indent=4, cls=JsonEncoder)
|
|
||||||
# run a text editor
|
|
||||||
self._cmd.run_editor(filename)
|
|
||||||
with open(filename, 'r') as text_file:
|
|
||||||
edited_json = json.load(text_file)
|
|
||||||
if edited_json == orig_json:
|
if edited_json == orig_json:
|
||||||
self._cmd.poutput("Data not modified, skipping write")
|
self._cmd.poutput("Data not modified, skipping write")
|
||||||
else:
|
else:
|
||||||
@@ -959,15 +1035,8 @@ class LinFixedEF(CardEF):
|
|||||||
def do_edit_record_decoded(self, opts):
|
def do_edit_record_decoded(self, opts):
|
||||||
"""Edit the JSON representation of one record in an editor."""
|
"""Edit the JSON representation of one record in an editor."""
|
||||||
(orig_json, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR)
|
(orig_json, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR)
|
||||||
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname:
|
ef = self._cmd.lchan.selected_file
|
||||||
filename = '%s/file' % dirname
|
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
|
||||||
# write existing data as JSON to file
|
|
||||||
with open(filename, 'w') as text_file:
|
|
||||||
json.dump(orig_json, text_file, indent=4, cls=JsonEncoder)
|
|
||||||
# run a text editor
|
|
||||||
self._cmd.run_editor(filename)
|
|
||||||
with open(filename, 'r') as text_file:
|
|
||||||
edited_json = json.load(text_file)
|
|
||||||
if edited_json == orig_json:
|
if edited_json == orig_json:
|
||||||
self._cmd.poutput("Data not modified, skipping write")
|
self._cmd.poutput("Data not modified, skipping write")
|
||||||
else:
|
else:
|
||||||
@@ -1543,14 +1612,14 @@ class CardModel(abc.ABC):
|
|||||||
card_atr = scc.get_atr()
|
card_atr = scc.get_atr()
|
||||||
for atr in cls._atrs:
|
for atr in cls._atrs:
|
||||||
if atr == card_atr:
|
if atr == card_atr:
|
||||||
print("Detected CardModel:", cls.__name__)
|
log.info("Detected CardModel: %s", cls.__name__)
|
||||||
return True
|
return True
|
||||||
# if nothing found try to just compare the Historical Bytes of the ATR
|
# if nothing found try to just compare the Historical Bytes of the ATR
|
||||||
card_atr_hb = decomposeATR(card_atr)['hb']
|
card_atr_hb = decomposeATR(card_atr)['hb']
|
||||||
for atr in cls._atrs:
|
for atr in cls._atrs:
|
||||||
atr_hb = decomposeATR(atr)['hb']
|
atr_hb = decomposeATR(atr)['hb']
|
||||||
if atr_hb == card_atr_hb:
|
if atr_hb == card_atr_hb:
|
||||||
print("Detected CardModel:", cls.__name__)
|
log.info("Detected CardModel: %s", cls.__name__)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import hashlib
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Optional, List, Dict, Tuple
|
from typing import Optional, List, Dict, Tuple
|
||||||
from construct import Optional as COptional
|
from construct import Optional as COptional
|
||||||
from construct import Struct, GreedyRange, FlagsEnum, Int16ub, Int24ub, Padding, Bit, Const
|
from construct import Struct, GreedyRange, FlagsEnum, Int16ub, Int24ub, Padding, Bit, Const
|
||||||
|
from construct import Construct, stream_read, stream_write
|
||||||
from Cryptodome.Random import get_random_bytes
|
from Cryptodome.Random import get_random_bytes
|
||||||
from Cryptodome.Cipher import DES, DES3, AES
|
from Cryptodome.Cipher import DES, DES3, AES
|
||||||
from osmocom.utils import *
|
from osmocom.utils import *
|
||||||
@@ -35,6 +37,9 @@ from pySim.filesystem import *
|
|||||||
from pySim.profile import CardProfile
|
from pySim.profile import CardProfile
|
||||||
from pySim.ota import SimFileAccessAndToolkitAppSpecParams
|
from pySim.ota import SimFileAccessAndToolkitAppSpecParams
|
||||||
from pySim.javacard import CapFile
|
from pySim.javacard import CapFile
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
|
log = PySimLogger.get(__name__)
|
||||||
|
|
||||||
# GPCS Table 11-48 Load Parameter Tags
|
# GPCS Table 11-48 Load Parameter Tags
|
||||||
class NonVolatileCodeMinMemoryReq(BER_TLV_IE, tag=0xC6):
|
class NonVolatileCodeMinMemoryReq(BER_TLV_IE, tag=0xC6):
|
||||||
@@ -148,6 +153,24 @@ sw_table = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PutKeyLength(Construct):
|
||||||
|
"""A length field of a PUT KEY data field, GP CardSpec v2.3.1 11.8.2.3.1
|
||||||
|
- all lengths ASN.1 BER-TLV (ITU-T X.690 Section 8.1.3)
|
||||||
|
- except that the length 128 may also be coded on one byte as '80' for backwards compatibility
|
||||||
|
80 does not introduce the indefinite form here which is unused in GP as far as i know.
|
||||||
|
That legacy form is accepted when parsing, but never generated, which agrees with the spec"""
|
||||||
|
def _parse(self, stream, context, path):
|
||||||
|
first = stream_read(stream, 1, path)[0]
|
||||||
|
if first <= 0x80:
|
||||||
|
return first
|
||||||
|
return int.from_bytes(stream_read(stream, first & 0x7f, path), 'big')
|
||||||
|
|
||||||
|
def _build(self, obj, stream, context, path):
|
||||||
|
data = bertlv_encode_len(obj)
|
||||||
|
stream_write(stream, data, len(data), path)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
# GlobalPlatform 2.1.1 Section 9.1.6
|
# GlobalPlatform 2.1.1 Section 9.1.6
|
||||||
KeyType = Enum(Byte, des=0x80,
|
KeyType = Enum(Byte, des=0x80,
|
||||||
tls_psk=0x85, # v2.3.1 Section 11.1.8
|
tls_psk=0x85, # v2.3.1 Section 11.1.8
|
||||||
@@ -276,7 +299,7 @@ class ListOfSupportedOptions(BER_TLV_IE, tag=0x81):
|
|||||||
class SupportedKeysForScp03(BER_TLV_IE, tag=0x82):
|
class SupportedKeysForScp03(BER_TLV_IE, tag=0x82):
|
||||||
_construct = FlagsEnum(Byte, aes128=0x01, aes192=0x02, aes256=0x04)
|
_construct = FlagsEnum(Byte, aes128=0x01, aes192=0x02, aes256=0x04)
|
||||||
class SupportedTlsCipherSuitesForScp81(BER_TLV_IE, tag=0x83):
|
class SupportedTlsCipherSuitesForScp81(BER_TLV_IE, tag=0x83):
|
||||||
_consuruct = GreedyRange(Int16ub)
|
_construct = GreedyRange(Int16ub)
|
||||||
class ScpInformation(BER_TLV_IE, tag=0xa0, nested=[ScpType, ListOfSupportedOptions, SupportedKeysForScp03,
|
class ScpInformation(BER_TLV_IE, tag=0xa0, nested=[ScpType, ListOfSupportedOptions, SupportedKeysForScp03,
|
||||||
SupportedTlsCipherSuitesForScp81]):
|
SupportedTlsCipherSuitesForScp81]):
|
||||||
pass
|
pass
|
||||||
@@ -319,7 +342,7 @@ class CurrentSecurityLevel(BER_TLV_IE, tag=0xd3):
|
|||||||
# GlobalPlatform v2.3.1 Section 11.3.3.1.3
|
# GlobalPlatform v2.3.1 Section 11.3.3.1.3
|
||||||
class ApplicationAID(BER_TLV_IE, tag=0x4f):
|
class ApplicationAID(BER_TLV_IE, tag=0x4f):
|
||||||
_construct = GreedyBytes
|
_construct = GreedyBytes
|
||||||
class ApplicationTemplate(BER_TLV_IE, tag=0x61, ntested=[ApplicationAID]):
|
class ApplicationTemplate(BER_TLV_IE, tag=0x61, nested=[ApplicationAID]):
|
||||||
pass
|
pass
|
||||||
class ListOfApplications(BER_TLV_IE, tag=0x2f00, nested=[ApplicationTemplate]):
|
class ListOfApplications(BER_TLV_IE, tag=0x2f00, nested=[ApplicationTemplate]):
|
||||||
pass
|
pass
|
||||||
@@ -512,6 +535,63 @@ class GpRegistryRelatedData(BER_TLV_IE, tag=0xe3, nested=[ApplicationAID, LifeCy
|
|||||||
ExecutableModuleAID, AssociatedSecurityDomainAID]):
|
ExecutableModuleAID, AssociatedSecurityDomainAID]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# GP CS v2.3.1 Table 11-36/11-37 possible data objects requested/returned from GET STATUS for each registry entry.
|
||||||
|
# Applications and Executable Load Files have _different_ sets, so a tag list requesting them has
|
||||||
|
# to match the subset because 11.4.2.3 warns that asking for a data object an entry does not have
|
||||||
|
# "may" be answered with an error status.
|
||||||
|
GetStatusTagListIEs = {
|
||||||
|
# Table 11-36 GP Application Data
|
||||||
|
'isd': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter,
|
||||||
|
ExecutableLoadFileAID, AssociatedSecurityDomainAID],
|
||||||
|
'applications': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter,
|
||||||
|
ExecutableLoadFileAID, AssociatedSecurityDomainAID],
|
||||||
|
# Table 11-37 GP Executable Load File Data. 84 only for the subset that asks for the modules (Note 2)!
|
||||||
|
'files': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber,
|
||||||
|
AssociatedSecurityDomainAID],
|
||||||
|
'files_and_modules': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber,
|
||||||
|
ExecutableModuleAID, AssociatedSecurityDomainAID],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_status_tag_list(subset: str) -> bytes:
|
||||||
|
"""Encode the GET STATUS tag list for the given status subset"""
|
||||||
|
tags = b''.join([bertlv_encode_tag(ie.tag) for ie in GetStatusTagListIEs[subset]])
|
||||||
|
return b'\x5c' + bertlv_encode_len(len(tags)) + tags
|
||||||
|
|
||||||
|
# GP CS v2.3.1 Appendix H.2 / Table H-1
|
||||||
|
# oid prefix {iso(1) member-body(2) country-USA(840) globalPlatform(114283)} + card management type 2
|
||||||
|
# afterwards GP version.
|
||||||
|
OID_GP_CARD_MGMT_TYPE = h2b('2a864886fc6b02')
|
||||||
|
|
||||||
|
def _find_tlv_value(decoded, key: str):
|
||||||
|
"""depth first search for the nested decoded TLV_IE dict/list"""
|
||||||
|
if isinstance(decoded, dict):
|
||||||
|
for k, v in decoded.items():
|
||||||
|
if k == key:
|
||||||
|
return v
|
||||||
|
found = _find_tlv_value(v, key)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
elif isinstance(decoded, list):
|
||||||
|
for item in decoded:
|
||||||
|
found = _find_tlv_value(item, key)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
def decode_gp_version(card_data: bytes) -> Optional[Tuple[int, ...]]:
|
||||||
|
"""GP version from Card Data returned by GET DATA, like (2, 1, 1) or (2, 2).
|
||||||
|
None if cm type OID is absent/unknown"""
|
||||||
|
cd = CardData()
|
||||||
|
cd.from_tlv(card_data)
|
||||||
|
ctv = _find_tlv_value(cd.to_dict(), 'card_management_type_and_version')
|
||||||
|
oid = _find_tlv_value(ctv, 'object_identifier') if ctv is not None else None
|
||||||
|
if oid is None:
|
||||||
|
return None
|
||||||
|
oid = h2b(oid) if isinstance(oid, str) else bytes(oid)
|
||||||
|
if not oid.startswith(OID_GP_CARD_MGMT_TYPE):
|
||||||
|
return None
|
||||||
|
return tuple(oid[len(OID_GP_CARD_MGMT_TYPE):])
|
||||||
|
|
||||||
# Application Dedicated File of a Security Domain
|
# Application Dedicated File of a Security Domain
|
||||||
class ADF_SD(CardADF):
|
class ADF_SD(CardADF):
|
||||||
StoreData = BitStruct('last_block'/Flag,
|
StoreData = BitStruct('last_block'/Flag,
|
||||||
@@ -562,14 +642,14 @@ class ADF_SD(CardADF):
|
|||||||
|
|
||||||
@cmd2.with_argparser(store_data_parser)
|
@cmd2.with_argparser(store_data_parser)
|
||||||
def do_store_data(self, opts):
|
def do_store_data(self, opts):
|
||||||
"""Perform the GlobalPlatform GET DATA command in order to store some card-specific data.
|
"""Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
|
||||||
See GlobalPlatform CardSpecification v2.3Section 11.11 for details."""
|
See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
|
||||||
response_permitted = opts.response == 'may_be_returned'
|
response_permitted = opts.response == 'may_be_returned'
|
||||||
self.store_data(h2b(opts.DATA), opts.data_structure, opts.encryption, response_permitted)
|
self.store_data(h2b(opts.DATA), opts.data_structure, opts.encryption, response_permitted)
|
||||||
|
|
||||||
def store_data(self, data: bytes, structure:str = 'none', encryption:str = 'none', response_permitted: bool = False) -> bytes:
|
def store_data(self, data: bytes, structure:str = 'none', encryption:str = 'none', response_permitted: bool = False) -> bytes:
|
||||||
"""Perform the GlobalPlatform GET DATA command in order to store some card-specific data.
|
"""Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
|
||||||
See GlobalPlatform CardSpecification v2.3Section 11.11 for details."""
|
See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
|
||||||
max_cmd_len = self._cmd.lchan.scc.max_cmd_len
|
max_cmd_len = self._cmd.lchan.scc.max_cmd_len
|
||||||
# Table 11-89 of GP Card Specification v2.3
|
# Table 11-89 of GP Card Specification v2.3
|
||||||
remainder = data
|
remainder = data
|
||||||
@@ -585,7 +665,7 @@ class ADF_SD(CardADF):
|
|||||||
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(chunk) + "00")
|
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(chunk) + "00")
|
||||||
block_nr += 1
|
block_nr += 1
|
||||||
response += data
|
response += data
|
||||||
return data
|
return h2b(response)
|
||||||
|
|
||||||
put_key_parser = argparse.ArgumentParser()
|
put_key_parser = argparse.ArgumentParser()
|
||||||
put_key_parser.add_argument('--old-key-version-nr', type=auto_uint8, default=0, help='Old Key Version Number')
|
put_key_parser.add_argument('--old-key-version-nr', type=auto_uint8, default=0, help='Old Key Version Number')
|
||||||
@@ -602,8 +682,8 @@ class ADF_SD(CardADF):
|
|||||||
See GlobalPlatform CardSpecification v2.3 Section 11.8 for details.
|
See GlobalPlatform CardSpecification v2.3 Section 11.8 for details.
|
||||||
|
|
||||||
The KCV (Key Check Values) can either be explicitly specified using `--key-check`, or will
|
The KCV (Key Check Values) can either be explicitly specified using `--key-check`, or will
|
||||||
otherwise be automatically generated for DES and AES keys. You can suppress the latter using
|
otherwise be automatically generated for DES, AES and TLS-PSK keys. You can suppress the
|
||||||
`--suppress-key-check`.
|
latter using `--suppress-key-check`.
|
||||||
|
|
||||||
Example (SCP80 KIC/KID/KIK):
|
Example (SCP80 KIC/KID/KIK):
|
||||||
put_key --key-version-nr 1 --key-id 0x01 --key-type aes --key-data 000102030405060708090a0b0c0d0e0f
|
put_key --key-version-nr 1 --key-id 0x01 --key-type aes --key-data 000102030405060708090a0b0c0d0e0f
|
||||||
@@ -620,33 +700,81 @@ class ADF_SD(CardADF):
|
|||||||
kdb = []
|
kdb = []
|
||||||
for i in range(0, len(opts.key_type)):
|
for i in range(0, len(opts.key_type)):
|
||||||
if opts.key_check and len(opts.key_check) > i:
|
if opts.key_check and len(opts.key_check) > i:
|
||||||
kcv = opts.key_check[i]
|
kcv = h2b(opts.key_check[i])
|
||||||
elif opts.suppress_key_check:
|
elif opts.suppress_key_check:
|
||||||
kcv = ''
|
kcv = b''
|
||||||
else:
|
else:
|
||||||
kcv_bin = compute_kcv(opts.key_type[i], h2b(opts.key_data[i])) or b''
|
kcv = compute_kcv(opts.key_type[i], h2b(opts.key_data[i])) or b''
|
||||||
kcv = b2h(kcv_bin)
|
kdb.append({'key_type': opts.key_type[i], 'clear_key': h2b(opts.key_data[i]), 'kcv': kcv})
|
||||||
if self._cmd.lchan.scc.scp:
|
|
||||||
# encrypted key data with DEK of current SCP
|
|
||||||
kcb = b2h(self._cmd.lchan.scc.scp.encrypt_key(h2b(opts.key_data[i])))
|
|
||||||
else:
|
|
||||||
# (for example) during personalization, DEK might not be required)
|
|
||||||
kcb = opts.key_data[i]
|
|
||||||
kdb.append({'key_type': opts.key_type[i], 'kcb': kcb, 'kcv': kcv})
|
|
||||||
p2 = opts.key_id
|
p2 = opts.key_id
|
||||||
if len(opts.key_type) > 1:
|
if len(opts.key_type) > 1:
|
||||||
p2 |= 0x80
|
p2 |= 0x80
|
||||||
self.put_key(opts.old_key_version_nr, opts.key_version_nr, p2, kdb)
|
self.put_key(opts.old_key_version_nr, opts.key_version_nr, p2, kdb)
|
||||||
|
|
||||||
# Table 11-68: Key Data Field - Format 1 (Basic Format)
|
# Table 11-68: Key Data Field - Format 1 (Basic Format). The key component block length is
|
||||||
KeyDataBasic = GreedyRange(Struct('key_type'/KeyType,
|
# BER-TLV coded (Section 11.8.2.3.1), the key check value length is always '00' - '7F'.
|
||||||
'kcb'/Prefixed(Int8ub, GreedyBytes),
|
KeyDataBasic = Struct('key_type'/KeyType,
|
||||||
'kcv'/Prefixed(Int8ub, GreedyBytes)))
|
'kcb'/Prefixed(PutKeyLength(), GreedyBytes),
|
||||||
|
'kcv'/Prefixed(Int8ub, GreedyBytes))
|
||||||
|
|
||||||
def put_key(self, old_kvn:int, kvn: int, kid: int, key_dict: dict) -> bytes:
|
@classmethod
|
||||||
|
def encode_key_data_basic(cls, key_type: str, kcb: bytes, kcv: bytes) -> bytes:
|
||||||
|
"""Generic Basic key data field, GP CardSpec v2.3 Table 11-68):
|
||||||
|
tag || L1 || <maybe L2> KCB || <1-byte length> KCV"""
|
||||||
|
return cls.KeyDataBasic.build({'key_type': key_type, 'kcb': kcb, 'kcv': kcv})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def encode_key_data_psk(cls, clear_key: bytes, ciphered_key: bytes, kcv: bytes) -> bytes:
|
||||||
|
"""Single PSK TLS '85' key data field per GP Amendment B 1.2, 3.9.1 / Table 3-13:
|
||||||
|
85 | L1 | <L2> <ciphered PSK key> | <KCV length> | <KCV>
|
||||||
|
- framing is like Basic Format, but the kcb is always GP CardSpec Table 11-70
|
||||||
|
so always with the length of the clear text key value, even without padding!
|
||||||
|
- 'ciphered_key' is DEK(block-padded clear key), no additional length prefix."""
|
||||||
|
kcb = bertlv_encode_len(len(clear_key)) + ciphered_key
|
||||||
|
return cls.encode_key_data_basic('tls_psk', kcb, kcv)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_put_key_data(cls, kvn: int, keys: List[dict], scp) -> bytes:
|
||||||
|
"""Assemble the PUT KEY data field, mixed PSK + DES DEK is supported:
|
||||||
|
- new KVN followed by one key data field per key.
|
||||||
|
- tls_psk keys per GP Amendment B
|
||||||
|
- other key types generic Basic format
|
||||||
|
Param 'keys' is a dict:
|
||||||
|
- 'key_type' (str)
|
||||||
|
- 'clear_key' (bytes)
|
||||||
|
- 'kcv' (bytes / empty).
|
||||||
|
'scp' may be None (e.g. during personalization, when the DEK may not be required)."""
|
||||||
|
key_data = kvn.to_bytes(1, 'big')
|
||||||
|
for k in keys:
|
||||||
|
clear = k['clear_key']
|
||||||
|
if k['key_type'] == 'tls_psk':
|
||||||
|
# len always part of the data see CardSpec Table 11-70 vs Table 11-71
|
||||||
|
if scp:
|
||||||
|
ciphered = scp.dek_encrypt(scp.pad_to_blocksize(clear))
|
||||||
|
else:
|
||||||
|
ciphered = clear
|
||||||
|
key_data += cls.encode_key_data_psk(clear, ciphered, k['kcv'])
|
||||||
|
else:
|
||||||
|
if scp:
|
||||||
|
ciphered = scp.encrypt_key(clear)
|
||||||
|
else:
|
||||||
|
# (for example) during personalization, DEK might not be required
|
||||||
|
ciphered = clear
|
||||||
|
key_data += cls.encode_key_data_basic(k['key_type'], ciphered, k['kcv'])
|
||||||
|
return key_data
|
||||||
|
|
||||||
|
def put_key(self, old_kvn:int, kvn: int, kid: int, keys: List[dict]) -> bytes:
|
||||||
"""Perform the GlobalPlatform PUT KEY command in order to store a new key on the card.
|
"""Perform the GlobalPlatform PUT KEY command in order to store a new key on the card.
|
||||||
See GlobalPlatform CardSpecification v2.3 Section 11.8 for details."""
|
See GlobalPlatform CardSpecification v2.3 Section 11.8 for details."""
|
||||||
key_data = kvn.to_bytes(1, 'big') + build_construct(ADF_SD.AddlShellCommands.KeyDataBasic, key_dict)
|
key_data = self.build_put_key_data(kvn, keys, self._cmd.lchan.scc.scp)
|
||||||
|
# Lc of Table 11-64 is a single byte, while LOAD or STORE DATA splits we can't:
|
||||||
|
# 11.8.2.3.3 splits a key at component boundaries -> not helping here
|
||||||
|
max_cmd_len = self._cmd.lchan.scc.max_cmd_len
|
||||||
|
if len(key_data) > max_cmd_len:
|
||||||
|
raise ValueError('key data field of %u bytes exceeds the maximum command length of %u '
|
||||||
|
'(limited by the overhead of the current secure channel); use fewer '
|
||||||
|
'keys per command, a single key component that large needs STORE DATA' %
|
||||||
|
(len(key_data), max_cmd_len))
|
||||||
hdr = "80D8%02x%02x%02x" % (old_kvn, kid, len(key_data))
|
hdr = "80D8%02x%02x%02x" % (old_kvn, kid, len(key_data))
|
||||||
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(key_data) + "00")
|
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(key_data) + "00")
|
||||||
return data
|
return data
|
||||||
@@ -665,26 +793,78 @@ class ADF_SD(CardADF):
|
|||||||
for grd in grd_list:
|
for grd in grd_list:
|
||||||
self._cmd.poutput_json(grd.to_dict())
|
self._cmd.poutput_json(grd.to_dict())
|
||||||
|
|
||||||
|
def gp_version(self) -> Optional[Tuple[int, ...]]:
|
||||||
|
"""GP version the selected SD reports in its Card Recognition
|
||||||
|
Data, e.g. (2, 1, 1). Card Recognition Data "shall be present" v2.1.1/v2.3.1 section 7.4.1.3,
|
||||||
|
so this must succeed no matter the GP version. None if card did not answer GET DATA / OID unknown.
|
||||||
|
Cached, it cannot change during a session."""
|
||||||
|
if not hasattr(self, '_gp_version'):
|
||||||
|
self._gp_version = None
|
||||||
|
try:
|
||||||
|
data, _sw = self._cmd.lchan.scc.get_data(cla=0x80, tag=CardData.tag)
|
||||||
|
self._gp_version = decode_gp_version(h2b(data))
|
||||||
|
except (SwMatchError, ValueError) as e:
|
||||||
|
log.warning("Could not determine GlobalPlatform version: %s", e)
|
||||||
|
return self._gp_version
|
||||||
|
|
||||||
def get_status(self, subset:str, aid_search_qualifier:Hexstr = '') -> List[GpRegistryRelatedData]:
|
def get_status(self, subset:str, aid_search_qualifier:Hexstr = '') -> List[GpRegistryRelatedData]:
|
||||||
subset_hex = b2h(build_construct(StatusSubset, subset))
|
|
||||||
aid = ApplicationAID(decoded=aid_search_qualifier)
|
aid = ApplicationAID(decoded=aid_search_qualifier)
|
||||||
cmd_data = aid.to_tlv() + h2b('5c054f9f70c5cc')
|
# GPC CardSpec v2.3.1 Table 11-35 says only the AID search tag is mandatory, tag list is
|
||||||
p2 = 0x02 # TLV format according to Table 11-36
|
# Optional and not present in the older v2.1.1, where section 9.4.2.3 defines the data
|
||||||
|
# field as the search qualifier.
|
||||||
|
# Cards like the sja5 implementing that old GP version reject anything else with 6A80
|
||||||
|
# from v2.1.1 Table 9-26 so only send a tag list to a card that announces v2.2 or later.
|
||||||
|
#
|
||||||
|
# Not sending one is not a problem on older cards, the tag list only gives us data beyond
|
||||||
|
# what 11.4.3.1 gives us anyway, for example the associated SD AID which matters on an eUICC
|
||||||
|
# where entries belong to different SD.
|
||||||
|
version = self.gp_version()
|
||||||
|
log.debug("Card Recognition Data reports GlobalPlatform %s",
|
||||||
|
'.'.join(str(v) for v in version) if version else 'unknown')
|
||||||
|
if version is not None and version >= (2, 2):
|
||||||
|
try:
|
||||||
|
return self._get_status(subset, aid.to_tlv() + get_status_tag_list(subset))
|
||||||
|
except SwMatchError as e:
|
||||||
|
# Retry if v2.2 or later but rejected the tag list anyway.
|
||||||
|
# 6A80 and 6A88 are the error conditions GET STATUS defines in table 11-39.
|
||||||
|
# Retrying beats not ending up with a list again...
|
||||||
|
if e.sw_actual not in ('6a80', '6a88'):
|
||||||
|
raise
|
||||||
|
log.warning("Card reports GlobalPlatform %s but answered %s to the GET STATUS tag list; "
|
||||||
|
"retrying with the default search",
|
||||||
|
'.'.join(str(v) for v in version), e.sw_actual)
|
||||||
|
return self._get_status(subset, aid.to_tlv(), empty_on_6a88=True)
|
||||||
|
|
||||||
|
def _get_status(self, subset:str, cmd_data:bytes,
|
||||||
|
empty_on_6a88: bool = False) -> List[GpRegistryRelatedData]:
|
||||||
|
subset_hex = b2h(build_construct(StatusSubset, subset))
|
||||||
|
p2 = 0x02 # GPC v2.3.1 11.4.2.2 table 11-34, b2: response data structure per table 11-36
|
||||||
grd_list = []
|
grd_list = []
|
||||||
while True:
|
while True:
|
||||||
hdr = "80F2%s%02x%02x" % (subset_hex, p2, len(cmd_data))
|
hdr = "80F2%s%02x%02x" % (subset_hex, p2, len(cmd_data))
|
||||||
data, sw = self._cmd.lchan.scc.send_apdu(hdr + b2h(cmd_data) + "00")
|
data, sw = self._cmd.lchan.scc.send_apdu(hdr + b2h(cmd_data) + "00")
|
||||||
|
if sw == '6a88':
|
||||||
|
# Table 11-39 "Referenced data not found". After collecting all pages this can
|
||||||
|
# only mean "nothing more matches" -> listing is complete. On the first page
|
||||||
|
# it is ambiguous, empty result or bad command data field, so leave that to get_status()
|
||||||
|
# which knows if a tag list was sent.
|
||||||
|
if grd_list or empty_on_6a88:
|
||||||
|
return grd_list
|
||||||
|
raise SwMatchError(sw, ['9000', '6310'])
|
||||||
|
if sw not in ['9000', '6310']:
|
||||||
|
# Never return a silently truncated registry
|
||||||
|
raise SwMatchError(sw, ['9000', '6310'])
|
||||||
remainder = h2b(data)
|
remainder = h2b(data)
|
||||||
while len(remainder):
|
while len(remainder):
|
||||||
# tlv sequence, each element is one GpRegistryRelatedData()
|
# tlv sequence, each element is one GpRegistryRelatedData()
|
||||||
grd = GpRegistryRelatedData()
|
grd = GpRegistryRelatedData()
|
||||||
_dec, remainder = grd.from_tlv(remainder)
|
_dec, remainder = grd.from_tlv(remainder)
|
||||||
grd_list.append(grd)
|
grd_list.append(grd)
|
||||||
if sw != '6310':
|
if sw == '9000':
|
||||||
return grd_list
|
return grd_list
|
||||||
else:
|
# 6310 = more data available, table 11-38: reissue as get next occurrence(s), b1 of
|
||||||
p2 |= 0x01
|
# table 11-34. Keeps b2 unchanged.
|
||||||
return grd_list
|
p2 |= 0x01
|
||||||
|
|
||||||
set_status_parser = argparse.ArgumentParser()
|
set_status_parser = argparse.ArgumentParser()
|
||||||
set_status_parser.add_argument('scope', choices=list(SetStatusScope.ksymapping.values()),
|
set_status_parser.add_argument('scope', choices=list(SetStatusScope.ksymapping.values()),
|
||||||
@@ -826,23 +1006,32 @@ class ADF_SD(CardADF):
|
|||||||
load_parser_from_grp.add_argument('--from-hex', type=is_hexstr, help='load from hex string')
|
load_parser_from_grp.add_argument('--from-hex', type=is_hexstr, help='load from hex string')
|
||||||
load_parser_from_grp.add_argument('--from-file', type=argparse.FileType('rb', 0), help='load from binary file')
|
load_parser_from_grp.add_argument('--from-file', type=argparse.FileType('rb', 0), help='load from binary file')
|
||||||
load_parser_from_grp.add_argument('--from-cap-file', type=argparse.FileType('rb', 0), help='load from JAVA-card CAP file')
|
load_parser_from_grp.add_argument('--from-cap-file', type=argparse.FileType('rb', 0), help='load from JAVA-card CAP file')
|
||||||
|
load_parser.add_argument('--chunk-len', type=auto_uint8, default=None,
|
||||||
|
help='Block size for the LOAD command; default: as large as the current secure channel overhead permits, at most 240')
|
||||||
|
|
||||||
@cmd2.with_argparser(load_parser)
|
@cmd2.with_argparser(load_parser)
|
||||||
def do_load(self, opts):
|
def do_load(self, opts):
|
||||||
"""Perform a GlobalPlatform LOAD command. (We currently only support loading without DAP and
|
"""Perform a GlobalPlatform LOAD command. (We currently only support loading without DAP and
|
||||||
without ciphering.)"""
|
without ciphering.)"""
|
||||||
if opts.from_hex is not None:
|
if opts.from_hex is not None:
|
||||||
self.load(h2b(opts.from_hex))
|
self.load(h2b(opts.from_hex), opts.chunk_len)
|
||||||
elif opts.from_file is not None:
|
elif opts.from_file is not None:
|
||||||
self.load(opts.from_file.read())
|
self.load(opts.from_file.read(), opts.chunk_len)
|
||||||
elif opts.from_cap_file is not None:
|
elif opts.from_cap_file is not None:
|
||||||
cap = CapFile(opts.from_cap_file)
|
cap = CapFile(opts.from_cap_file)
|
||||||
self.load(cap.get_loadfile())
|
self.load(cap.get_loadfile(), opts.chunk_len)
|
||||||
else:
|
else:
|
||||||
raise ValueError('load source not specified!')
|
raise ValueError('load source not specified!')
|
||||||
|
|
||||||
def load(self, contents:bytes, chunk_len:int = 240):
|
def load(self, contents:bytes, chunk_len:Optional[int] = None):
|
||||||
# TODO:tune chunk_len based on the overhead of the used SCP?
|
# scc.max_cmd_len knows the overhead the currently active SCP
|
||||||
|
# 240 is the old default, keep it for now.
|
||||||
|
max_chunk_len = self._cmd.lchan.scc.max_cmd_len
|
||||||
|
if chunk_len is None:
|
||||||
|
chunk_len = min(240, max_chunk_len)
|
||||||
|
elif not 1 <= chunk_len <= max_chunk_len:
|
||||||
|
raise ValueError('chunk_len must be in range 1..%u (limited by the overhead of the current secure channel)' %
|
||||||
|
max_chunk_len)
|
||||||
# build TLV according to GPC_SPE_034 section 11.6.2.3 / Table 11-58 for unencrypted case
|
# build TLV according to GPC_SPE_034 section 11.6.2.3 / Table 11-58 for unencrypted case
|
||||||
remainder = b'\xC4' + bertlv_encode_len(len(contents)) + contents
|
remainder = b'\xC4' + bertlv_encode_len(len(contents)) + contents
|
||||||
# transfer this in various chunks to the card
|
# transfer this in various chunks to the card
|
||||||
@@ -859,22 +1048,30 @@ class ADF_SD(CardADF):
|
|||||||
_rsp_hex, _sw = self._cmd.lchan.scc.send_apdu_checksw(cmd_hex)
|
_rsp_hex, _sw = self._cmd.lchan.scc.send_apdu_checksw(cmd_hex)
|
||||||
self._cmd.poutput("Loaded a total of %u bytes in %u blocks. Don't forget install_for_install (and make selectable) now!" % (total_size, block_nr))
|
self._cmd.poutput("Loaded a total of %u bytes in %u blocks. Don't forget install_for_install (and make selectable) now!" % (total_size, block_nr))
|
||||||
|
|
||||||
install_cap_parser = argparse.ArgumentParser()
|
install_cap_parser = argparse.ArgumentParser(usage='%(prog)s FILE [--install-parameters | --install-parameters-*]')
|
||||||
install_cap_parser.add_argument('cap_file', type=str, metavar='FILE',
|
install_cap_parser.add_argument('cap_file', type=str, metavar='FILE',
|
||||||
help='JAVA-CARD CAP file to install')
|
help='JAVA-CARD CAP file to install')
|
||||||
install_cap_parser_inst_prm_g = install_cap_parser.add_mutually_exclusive_group()
|
# Ideally, the parser should enforce that:
|
||||||
install_cap_parser_inst_prm_g.add_argument('--install-parameters', type=is_hexstr, default=None,
|
# * either the `--install-parameters` is given alone,
|
||||||
help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
# * or distinct `--install-parameters-*` are optionally given instead.
|
||||||
install_cap_parser_inst_prm_g_grp = install_cap_parser_inst_prm_g.add_argument_group()
|
# We tried to achieve this using mutually exclusive groups (add_mutually_exclusive_group).
|
||||||
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-volatile-memory-quota',
|
# However, group nesting was never supported, often failed to work correctly, and was unintentionally
|
||||||
type=int, default=None,
|
# exposed through inheritance. It has been deprecated since version 3.11, removed in version 3.14.
|
||||||
help='volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
# Hence, we have to implement the enforcement manually.
|
||||||
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-non-volatile-memory-quota',
|
install_cap_parser_inst_prm_grp = install_cap_parser.add_argument_group('Install Parameters')
|
||||||
type=int, default=None,
|
install_cap_parser_inst_prm_grp.add_argument('--install-parameters', type=is_hexstr, default=None,
|
||||||
help='non volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
||||||
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-stk',
|
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-volatile-memory-quota',
|
||||||
type=is_hexstr, default=None,
|
type=int, default=None,
|
||||||
help='Load Parameters (ETSI TS 102 226, section 8.2.1.3.2.1)')
|
help='volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
||||||
|
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-non-volatile-memory-quota',
|
||||||
|
type=int, default=None,
|
||||||
|
help='non volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
||||||
|
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-stk',
|
||||||
|
type=is_hexstr, default=None,
|
||||||
|
help='Load Parameters (ETSI TS 102 226, section 8.2.1.3.2.1)')
|
||||||
|
install_cap_parser.add_argument('--chunk-len', type=auto_uint8, default=None,
|
||||||
|
help='Block size for the LOAD command; default: as large as the current secure channel overhead permits, at most 240')
|
||||||
|
|
||||||
@cmd2.with_argparser(install_cap_parser)
|
@cmd2.with_argparser(install_cap_parser)
|
||||||
def do_install_cap(self, opts):
|
def do_install_cap(self, opts):
|
||||||
@@ -888,9 +1085,17 @@ class ADF_SD(CardADF):
|
|||||||
load_file_aid = cap.get_loadfile_aid()
|
load_file_aid = cap.get_loadfile_aid()
|
||||||
module_aid = cap.get_applet_aid()
|
module_aid = cap.get_applet_aid()
|
||||||
application_aid = module_aid
|
application_aid = module_aid
|
||||||
if opts.install_parameters:
|
if opts.install_parameters is not None:
|
||||||
|
# `--install-parameters` and `--install-parameters-*` are mutually exclusive
|
||||||
|
# make sure that none of `--install-parameters-*` is given; abort otherwise
|
||||||
|
if any(p is not None for p in [opts.install_parameters_non_volatile_memory_quota,
|
||||||
|
opts.install_parameters_volatile_memory_quota,
|
||||||
|
opts.install_parameters_stk]):
|
||||||
|
self.install_cap_parser.error('arguments --install-parameters-* are '
|
||||||
|
'not allowed with --install-parameters')
|
||||||
install_parameters = opts.install_parameters;
|
install_parameters = opts.install_parameters;
|
||||||
else:
|
else:
|
||||||
|
# `--install-parameters-*` are all optional
|
||||||
install_parameters = gen_install_parameters(opts.install_parameters_non_volatile_memory_quota,
|
install_parameters = gen_install_parameters(opts.install_parameters_non_volatile_memory_quota,
|
||||||
opts.install_parameters_volatile_memory_quota,
|
opts.install_parameters_volatile_memory_quota,
|
||||||
opts.install_parameters_stk)
|
opts.install_parameters_stk)
|
||||||
@@ -905,7 +1110,7 @@ class ADF_SD(CardADF):
|
|||||||
self._cmd.poutput("step #1: install for load...")
|
self._cmd.poutput("step #1: install for load...")
|
||||||
self.do_install_for_load("--load-file-aid %s --security-domain-aid %s" % (load_file_aid, security_domain_aid))
|
self.do_install_for_load("--load-file-aid %s --security-domain-aid %s" % (load_file_aid, security_domain_aid))
|
||||||
self._cmd.poutput("step #2: load...")
|
self._cmd.poutput("step #2: load...")
|
||||||
self.load(load_file)
|
self.load(load_file, opts.chunk_len)
|
||||||
self._cmd.poutput("step #3: install_for_install (and make selectable)...")
|
self._cmd.poutput("step #3: install_for_install (and make selectable)...")
|
||||||
self.do_install_for_install("--load-file-aid %s --module-aid %s --application-aid %s --install-parameters %s --make-selectable" %
|
self.do_install_for_install("--load-file-aid %s --module-aid %s --application-aid %s --install-parameters %s --make-selectable" %
|
||||||
(load_file_aid, module_aid, application_aid, install_parameters))
|
(load_file_aid, module_aid, application_aid, install_parameters))
|
||||||
@@ -1051,10 +1256,16 @@ def compute_kcv_aes(key:bytes) -> bytes:
|
|||||||
cipher = AES.new(key, AES.MODE_ECB)
|
cipher = AES.new(key, AES.MODE_ECB)
|
||||||
return cipher.encrypt(plaintext)
|
return cipher.encrypt(plaintext)
|
||||||
|
|
||||||
|
def compute_kcv_psk(key:bytes) -> bytes:
|
||||||
|
# GP Amendment B v1.2, 3.9.1 / Table 3-13
|
||||||
|
# KCV of a PSK TLS key is the 3 highest-order bytes of the SHA-1 digest of the clear key value.
|
||||||
|
return hashlib.sha1(key).digest()
|
||||||
|
|
||||||
# dict is keyed by the string name of the KeyType enum above in this file
|
# dict is keyed by the string name of the KeyType enum above in this file
|
||||||
KCV_CALCULATOR = {
|
KCV_CALCULATOR = {
|
||||||
'aes': compute_kcv_aes,
|
'aes': compute_kcv_aes,
|
||||||
'des': compute_kcv_des,
|
'des': compute_kcv_des,
|
||||||
|
'tls_psk': compute_kcv_psk,
|
||||||
}
|
}
|
||||||
|
|
||||||
def compute_kcv(key_type: str, key: bytes) -> Optional[bytes]:
|
def compute_kcv(key_type: str, key: bytes) -> Optional[bytes]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GlobalPlatform install parameter generator
|
# GlobalPlatform install parameter generator
|
||||||
#
|
#
|
||||||
# (C) 2024 by Sysmocom s.f.m.c. GmbH
|
# (C) 2024 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from osmocom.construct import *
|
from osmocom.construct import *
|
||||||
from osmocom.utils import *
|
from osmocom.utils import *
|
||||||
from osmocom.tlv import *
|
from osmocom.tlv import *
|
||||||
@@ -46,7 +48,9 @@ class InstallParams(TLV_IE_Collection, nested=[AppSpecificParams, SystemSpecific
|
|||||||
# GPD_SPE_013, table 11-49
|
# GPD_SPE_013, table 11-49
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def gen_install_parameters(non_volatile_memory_quota:int, volatile_memory_quota:int, stk_parameter:str):
|
def gen_install_parameters(non_volatile_memory_quota: Optional[int] = None,
|
||||||
|
volatile_memory_quota: Optional[int] = None,
|
||||||
|
stk_parameter: Optional[str] = None):
|
||||||
|
|
||||||
# GPD_SPE_013, table 11-49
|
# GPD_SPE_013, table 11-49
|
||||||
|
|
||||||
@@ -54,19 +58,17 @@ def gen_install_parameters(non_volatile_memory_quota:int, volatile_memory_quota:
|
|||||||
install_params = InstallParams()
|
install_params = InstallParams()
|
||||||
install_params_dict = [{'app_specific_params': None}]
|
install_params_dict = [{'app_specific_params': None}]
|
||||||
|
|
||||||
#Conditional
|
# Collect system specific parameters (optional)
|
||||||
if non_volatile_memory_quota and volatile_memory_quota and stk_parameter:
|
system_specific_params = []
|
||||||
system_specific_params = []
|
if non_volatile_memory_quota is not None:
|
||||||
#Optional
|
system_specific_params.append({'non_volatile_memory_quota': non_volatile_memory_quota})
|
||||||
if non_volatile_memory_quota:
|
if volatile_memory_quota is not None:
|
||||||
system_specific_params += [{'non_volatile_memory_quota': non_volatile_memory_quota}]
|
system_specific_params.append({'volatile_memory_quota': volatile_memory_quota})
|
||||||
#Optional
|
if stk_parameter is not None:
|
||||||
if volatile_memory_quota:
|
system_specific_params.append({'stk_parameter': stk_parameter})
|
||||||
system_specific_params += [{'volatile_memory_quota': volatile_memory_quota}]
|
# Add system specific parameters to the install parameters, if any
|
||||||
#Optional
|
if system_specific_params:
|
||||||
if stk_parameter:
|
install_params_dict.append({'system_specific_params': system_specific_params})
|
||||||
system_specific_params += [{'stk_parameter': stk_parameter}]
|
|
||||||
install_params_dict += [{'system_specific_params': system_specific_params}]
|
|
||||||
|
|
||||||
install_params.from_dict(install_params_dict)
|
install_params.from_dict(install_params_dict)
|
||||||
return b2h(install_params.to_bytes())
|
return b2h(install_params.to_bytes())
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ from osmocom.utils import b2h
|
|||||||
from osmocom.tlv import bertlv_parse_len, bertlv_encode_len
|
from osmocom.tlv import bertlv_parse_len, bertlv_encode_len
|
||||||
from pySim.utils import parse_command_apdu
|
from pySim.utils import parse_command_apdu
|
||||||
from pySim.secure_channel import SecureChannel
|
from pySim.secure_channel import SecureChannel
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
log = PySimLogger.get(__name__)
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
def scp02_key_derivation(constant: bytes, counter: int, base_key: bytes) -> bytes:
|
def scp02_key_derivation(constant: bytes, counter: int, base_key: bytes) -> bytes:
|
||||||
assert len(constant) == 2
|
assert len(constant) == 2
|
||||||
@@ -75,7 +75,7 @@ class Scp02SessionKeys:
|
|||||||
h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)])))
|
h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)])))
|
||||||
h = d.decrypt(h)
|
h = d.decrypt(h)
|
||||||
h = e.encrypt(h)
|
h = e.encrypt(h)
|
||||||
logger.debug("mac_1des(%s,icv=%s) -> %s", b2h(data), b2h(icv), b2h(h))
|
log.debug("mac_1des(%s,icv=%s) -> %s", b2h(data), b2h(icv), b2h(h))
|
||||||
if self.des_icv_enc:
|
if self.des_icv_enc:
|
||||||
self.icv = self.des_icv_enc.encrypt(h)
|
self.icv = self.des_icv_enc.encrypt(h)
|
||||||
else:
|
else:
|
||||||
@@ -89,7 +89,7 @@ class Scp02SessionKeys:
|
|||||||
h = b'\x00' * 8
|
h = b'\x00' * 8
|
||||||
for i in range(q):
|
for i in range(q):
|
||||||
h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)])))
|
h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)])))
|
||||||
logger.debug("mac_3des(%s) -> %s", b2h(data), b2h(h))
|
log.debug("mac_3des(%s) -> %s", b2h(data), b2h(h))
|
||||||
return h
|
return h
|
||||||
|
|
||||||
def __init__(self, counter: int, card_keys: 'GpCardKeyset', icv_encrypt=True):
|
def __init__(self, counter: int, card_keys: 'GpCardKeyset', icv_encrypt=True):
|
||||||
@@ -182,6 +182,29 @@ class SCP(SecureChannel, abc.ABC):
|
|||||||
"""Should we perform R-ENC?"""
|
"""Should we perform R-ENC?"""
|
||||||
return self.security_level & 0x20
|
return self.security_level & 0x20
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def mac_len(self) -> int:
|
||||||
|
"""Length of the appended C-MAC, to be provided by derived class."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overhead(self) -> int:
|
||||||
|
"""Worst-case len that wrapping a command APDU adds to its data field at the
|
||||||
|
current sec level is (255 - overhead), C-MAC + C-DECRYPTION encryption padding."""
|
||||||
|
if not self.do_cmac:
|
||||||
|
return 0
|
||||||
|
if not self.do_cenc:
|
||||||
|
return self.mac_len
|
||||||
|
# see Secure Channel Protocol '03' Card Specification v2.3 - Amendment D v1.1.2
|
||||||
|
# which defers to GPCS v2.3 Section B.2 which then defers to
|
||||||
|
# NIST SP 800-38B for encryption and points out that
|
||||||
|
# the padding is, as expected, just the usual padding from NIST SP 800-38A
|
||||||
|
# C-DECRYPTION pads with ('80'+['00'...] at least 1 byte) up to
|
||||||
|
# the cipher block size + C-MAC on top -> largest usable data field
|
||||||
|
# is one byte less than the largest block-size multiple within 255 - mac_len.
|
||||||
|
bs = self.sk.blocksize
|
||||||
|
return 255 - ((255 - self.mac_len) // bs * bs - 1)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return "%s[%02x]" % (self.__class__.__name__, self.security_level)
|
return "%s[%02x]" % (self.__class__.__name__, self.security_level)
|
||||||
|
|
||||||
@@ -215,11 +238,20 @@ class SCP(SecureChannel, abc.ABC):
|
|||||||
def gen_ext_auth_apdu(self, security_level: int = 0x01) -> bytes:
|
def gen_ext_auth_apdu(self, security_level: int = 0x01) -> bytes:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def pad_to_blocksize(self, data: bytes) -> bytes:
|
||||||
|
"""Right pad the data with zero bytes to a multiple of the DEK cipher block size."""
|
||||||
|
if len(data) % self.sk.blocksize:
|
||||||
|
# not '+=' which would mutate the callers bytearray in place..
|
||||||
|
data = data + b'\x00' * (self.sk.blocksize - len(data) % self.sk.blocksize)
|
||||||
|
return data
|
||||||
|
|
||||||
def encrypt_key(self, key: bytes) -> bytes:
|
def encrypt_key(self, key: bytes) -> bytes:
|
||||||
"""Encrypt a key with the DEK."""
|
"""Encrypt a key with the DEK."""
|
||||||
num_pad = len(key) % self.sk.blocksize
|
if len(key) % self.sk.blocksize:
|
||||||
if num_pad:
|
# The kcv is right padded before encryption and the kcb
|
||||||
return bertlv_encode_len(len(key)) + self.dek_encrypt(key + b'\x00'*num_pad)
|
# is formatted as described in Table 11-70: preceded by the actual length of the
|
||||||
|
# clear text kcv.
|
||||||
|
return bertlv_encode_len(len(key)) + self.dek_encrypt(self.pad_to_blocksize(key))
|
||||||
return self.dek_encrypt(key)
|
return self.dek_encrypt(key)
|
||||||
|
|
||||||
def decrypt_key(self, encrypted_key:bytes) -> bytes:
|
def decrypt_key(self, encrypted_key:bytes) -> bytes:
|
||||||
@@ -232,9 +264,8 @@ class SCP(SecureChannel, abc.ABC):
|
|||||||
# Block provides the actual length of the key component value, which allows recovering the
|
# Block provides the actual length of the key component value, which allows recovering the
|
||||||
# clear-text key component value after decryption of the encrypted key component value and removal
|
# clear-text key component value after decryption of the encrypted key component value and removal
|
||||||
# of padding bytes.
|
# of padding bytes.
|
||||||
decrypted = self.dek_decrypt(encrypted_key)
|
key_len, remainder = bertlv_parse_len(encrypted_key)
|
||||||
key_len, remainder = bertlv_parse_len(decrypted)
|
return self.dek_decrypt(remainder)[:key_len]
|
||||||
return remainder[:key_len]
|
|
||||||
else:
|
else:
|
||||||
# If the length of the Key Component Block is a multiple of the block size of the encryption
|
# If the length of the Key Component Block is a multiple of the block size of the encryption
|
||||||
# algorithm (i.e. 8 bytes for DES, 16 bytes for AES), then it shall be assumed that no padding
|
# algorithm (i.e. 8 bytes for DES, 16 bytes for AES), then it shall be assumed that no padding
|
||||||
@@ -260,24 +291,24 @@ class SCP02(SCP):
|
|||||||
# Key Version Number 0x70 is a non-spec special-case of sysmoISIM-SJA2/SJA5 and possibly more sysmocom products
|
# Key Version Number 0x70 is a non-spec special-case of sysmoISIM-SJA2/SJA5 and possibly more sysmocom products
|
||||||
# Key Version Number 0x01 is a non-spec special-case of sysmoUSIM-SJS1
|
# Key Version Number 0x01 is a non-spec special-case of sysmoUSIM-SJS1
|
||||||
kvn_ranges = [[0x01, 0x01], [0x20, 0x2f], [0x70, 0x70]]
|
kvn_ranges = [[0x01, 0x01], [0x20, 0x2f], [0x70, 0x70]]
|
||||||
|
# C-MAC (Single DES + final 3DES, B.1.2.2) is always one full DES block
|
||||||
def __init__(self, *args, **kwargs):
|
mac_len = 8
|
||||||
self.overhead = 8
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def dek_encrypt(self, plaintext:bytes) -> bytes:
|
def dek_encrypt(self, plaintext:bytes) -> bytes:
|
||||||
cipher = DES.new(self.card_keys.dek[:8], DES.MODE_ECB)
|
# See also GPC section B.1.1.2, E.4.7, and E.4.1
|
||||||
|
cipher = DES3.new(self.sk.data_enc, DES.MODE_ECB)
|
||||||
return cipher.encrypt(plaintext)
|
return cipher.encrypt(plaintext)
|
||||||
|
|
||||||
def dek_decrypt(self, ciphertext:bytes) -> bytes:
|
def dek_decrypt(self, ciphertext:bytes) -> bytes:
|
||||||
cipher = DES.new(self.card_keys.dek[:8], DES.MODE_ECB)
|
# See also GPC section B.1.1.2, E.4.7, and E.4.1
|
||||||
|
cipher = DES3.new(self.sk.data_enc, DES.MODE_ECB)
|
||||||
return cipher.decrypt(ciphertext)
|
return cipher.decrypt(ciphertext)
|
||||||
|
|
||||||
def _compute_cryptograms(self, card_challenge: bytes, host_challenge: bytes):
|
def _compute_cryptograms(self, card_challenge: bytes, host_challenge: bytes):
|
||||||
logger.debug("host_challenge(%s), card_challenge(%s)", b2h(host_challenge), b2h(card_challenge))
|
log.debug("host_challenge(%s), card_challenge(%s)", b2h(host_challenge), b2h(card_challenge))
|
||||||
self.host_cryptogram = self.sk.calc_mac_3des(self.sk.counter.to_bytes(2, 'big') + card_challenge + host_challenge)
|
self.host_cryptogram = self.sk.calc_mac_3des(self.sk.counter.to_bytes(2, 'big') + card_challenge + host_challenge)
|
||||||
self.card_cryptogram = self.sk.calc_mac_3des(self.host_challenge + self.sk.counter.to_bytes(2, 'big') + card_challenge)
|
self.card_cryptogram = self.sk.calc_mac_3des(self.host_challenge + self.sk.counter.to_bytes(2, 'big') + card_challenge)
|
||||||
logger.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram))
|
log.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram))
|
||||||
|
|
||||||
def gen_init_update_apdu(self, host_challenge: bytes = b'\x00'*8) -> bytes:
|
def gen_init_update_apdu(self, host_challenge: bytes = b'\x00'*8) -> bytes:
|
||||||
"""Generate INITIALIZE UPDATE APDU."""
|
"""Generate INITIALIZE UPDATE APDU."""
|
||||||
@@ -289,7 +320,7 @@ class SCP02(SCP):
|
|||||||
resp = self.constr_iur.parse(resp_bin)
|
resp = self.constr_iur.parse(resp_bin)
|
||||||
self.card_challenge = resp['card_challenge']
|
self.card_challenge = resp['card_challenge']
|
||||||
self.sk = Scp02SessionKeys(resp['seq_counter'], self.card_keys)
|
self.sk = Scp02SessionKeys(resp['seq_counter'], self.card_keys)
|
||||||
logger.debug(self.sk)
|
log.debug(self.sk)
|
||||||
self._compute_cryptograms(self.card_challenge, self.host_challenge)
|
self._compute_cryptograms(self.card_challenge, self.host_challenge)
|
||||||
if self.card_cryptogram != resp['card_cryptogram']:
|
if self.card_cryptogram != resp['card_cryptogram']:
|
||||||
raise ValueError("card cryptogram doesn't match")
|
raise ValueError("card cryptogram doesn't match")
|
||||||
@@ -309,7 +340,7 @@ class SCP02(SCP):
|
|||||||
|
|
||||||
def _wrap_cmd_apdu(self, apdu: bytes, *args, **kwargs) -> bytes:
|
def _wrap_cmd_apdu(self, apdu: bytes, *args, **kwargs) -> bytes:
|
||||||
"""Wrap Command APDU for SCP02: calculate MAC and encrypt."""
|
"""Wrap Command APDU for SCP02: calculate MAC and encrypt."""
|
||||||
logger.debug("wrap_cmd_apdu(%s)", b2h(apdu))
|
log.debug("wrap_cmd_apdu(%s)", b2h(apdu))
|
||||||
|
|
||||||
if not self.do_cmac:
|
if not self.do_cmac:
|
||||||
return apdu
|
return apdu
|
||||||
@@ -336,10 +367,16 @@ class SCP02(SCP):
|
|||||||
# CMAC on modified APDU
|
# CMAC on modified APDU
|
||||||
mlc = lc + 8
|
mlc = lc + 8
|
||||||
clac = cla | CLA_SM
|
clac = cla | CLA_SM
|
||||||
|
if mlc >= 256:
|
||||||
|
raise ValueError('Modified Lc (%u) would exceed maximum when appending 8 bytes of mac' % mlc)
|
||||||
mac = self.sk.calc_mac_1des(bytes([clac]) + apdu[1:4] + bytes([mlc]) + data)
|
mac = self.sk.calc_mac_1des(bytes([clac]) + apdu[1:4] + bytes([mlc]) + data)
|
||||||
if self.do_cenc:
|
if self.do_cenc:
|
||||||
|
padded_data = pad80(data, 8)
|
||||||
|
if len(padded_data) + 8 >= 256:
|
||||||
|
raise ValueError('Modified Lc (%u) would exceed maximum when appending padding and mac' %
|
||||||
|
(len(padded_data) + 8))
|
||||||
k = DES3.new(self.sk.enc, DES.MODE_CBC, b'\x00'*8)
|
k = DES3.new(self.sk.enc, DES.MODE_CBC, b'\x00'*8)
|
||||||
data = k.encrypt(pad80(data, 8))
|
data = k.encrypt(padded_data)
|
||||||
lc = len(data)
|
lc = len(data)
|
||||||
|
|
||||||
lc += 8
|
lc += 8
|
||||||
@@ -376,7 +413,7 @@ def scp03_key_derivation(constant: bytes, context: bytes, base_key: bytes, l: Op
|
|||||||
if l is None:
|
if l is None:
|
||||||
l = len(base_key) * 8
|
l = len(base_key) * 8
|
||||||
|
|
||||||
logger.debug("scp03_kdf(constant=%s, context=%s, base_key=%s, l=%u)", b2h(constant), b2h(context), b2h(base_key), l)
|
log.debug("scp03_kdf(constant=%s, context=%s, base_key=%s, l=%u)", b2h(constant), b2h(context), b2h(base_key), l)
|
||||||
output_len = l // 8
|
output_len = l // 8
|
||||||
# SCP03 Section 4.1.5 defines a different parameter order than NIST SP 800-108, so we cannot use the
|
# SCP03 Section 4.1.5 defines a different parameter order than NIST SP 800-108, so we cannot use the
|
||||||
# existing Cryptodome.Protocol.KDF.SP800_108_Counter function :(
|
# existing Cryptodome.Protocol.KDF.SP800_108_Counter function :(
|
||||||
@@ -436,7 +473,7 @@ class Scp03SessionKeys:
|
|||||||
"""Obtain the ICV value computed as described in 6.2.6.
|
"""Obtain the ICV value computed as described in 6.2.6.
|
||||||
This method has two modes:
|
This method has two modes:
|
||||||
* is_response=False for computing the ICV for C-ENC. Will pre-increment the counter.
|
* is_response=False for computing the ICV for C-ENC. Will pre-increment the counter.
|
||||||
* is_response=False for computing the ICV for R-DEC."""
|
* is_response=True for computing the ICV for R-DEC."""
|
||||||
if not is_response:
|
if not is_response:
|
||||||
self.block_nr += 1
|
self.block_nr += 1
|
||||||
# The binary value of this number SHALL be left padded with zeroes to form a full block.
|
# The binary value of this number SHALL be left padded with zeroes to form a full block.
|
||||||
@@ -449,7 +486,7 @@ class Scp03SessionKeys:
|
|||||||
# This block SHALL be encrypted with S-ENC to produce the ICV for command encryption.
|
# This block SHALL be encrypted with S-ENC to produce the ICV for command encryption.
|
||||||
cipher = AES.new(self.s_enc, AES.MODE_CBC, iv)
|
cipher = AES.new(self.s_enc, AES.MODE_CBC, iv)
|
||||||
icv = cipher.encrypt(data)
|
icv = cipher.encrypt(data)
|
||||||
logger.debug("_get_icv(data=%s, is_resp=%s) -> icv=%s", b2h(data), is_response, b2h(icv))
|
log.debug("_get_icv(data=%s, is_resp=%s) -> icv=%s", b2h(data), is_response, b2h(icv))
|
||||||
return icv
|
return icv
|
||||||
|
|
||||||
# TODO: Resolve duplication with pySim.esim.bsp.BspAlgoCryptAES128 which provides pad80-wrapping
|
# TODO: Resolve duplication with pySim.esim.bsp.BspAlgoCryptAES128 which provides pad80-wrapping
|
||||||
@@ -475,9 +512,13 @@ class SCP03(SCP):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.s_mode = kwargs.pop('s_mode', 8)
|
self.s_mode = kwargs.pop('s_mode', 8)
|
||||||
self.overhead = self.s_mode
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mac_len(self) -> int:
|
||||||
|
# C-MAC truncated to 8 in S8 or 16 bytes in S16 mode
|
||||||
|
return self.s_mode
|
||||||
|
|
||||||
def dek_encrypt(self, plaintext:bytes) -> bytes:
|
def dek_encrypt(self, plaintext:bytes) -> bytes:
|
||||||
cipher = AES.new(self.card_keys.dek, AES.MODE_CBC, b'\x00'*16)
|
cipher = AES.new(self.card_keys.dek, AES.MODE_CBC, b'\x00'*16)
|
||||||
return cipher.encrypt(plaintext)
|
return cipher.encrypt(plaintext)
|
||||||
@@ -487,12 +528,12 @@ class SCP03(SCP):
|
|||||||
return cipher.decrypt(ciphertext)
|
return cipher.decrypt(ciphertext)
|
||||||
|
|
||||||
def _compute_cryptograms(self):
|
def _compute_cryptograms(self):
|
||||||
logger.debug("host_challenge(%s), card_challenge(%s)", b2h(self.host_challenge), b2h(self.card_challenge))
|
log.debug("host_challenge(%s), card_challenge(%s)", b2h(self.host_challenge), b2h(self.card_challenge))
|
||||||
# Card + Host Authentication Cryptogram: Section 6.2.2.2 + 6.2.2.3
|
# Card + Host Authentication Cryptogram: Section 6.2.2.2 + 6.2.2.3
|
||||||
context = self.host_challenge + self.card_challenge
|
context = self.host_challenge + self.card_challenge
|
||||||
self.card_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_CARD, context, self.sk.s_mac, l=self.s_mode*8)
|
self.card_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_CARD, context, self.sk.s_mac, l=self.s_mode*8)
|
||||||
self.host_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_HOST, context, self.sk.s_mac, l=self.s_mode*8)
|
self.host_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_HOST, context, self.sk.s_mac, l=self.s_mode*8)
|
||||||
logger.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram))
|
log.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram))
|
||||||
|
|
||||||
def gen_init_update_apdu(self, host_challenge: Optional[bytes] = None) -> bytes:
|
def gen_init_update_apdu(self, host_challenge: Optional[bytes] = None) -> bytes:
|
||||||
"""Generate INITIALIZE UPDATE APDU."""
|
"""Generate INITIALIZE UPDATE APDU."""
|
||||||
@@ -512,7 +553,7 @@ class SCP03(SCP):
|
|||||||
self.i_param = resp['i_param']
|
self.i_param = resp['i_param']
|
||||||
# derive session keys and compute cryptograms
|
# derive session keys and compute cryptograms
|
||||||
self.sk = Scp03SessionKeys(self.card_keys, self.host_challenge, self.card_challenge)
|
self.sk = Scp03SessionKeys(self.card_keys, self.host_challenge, self.card_challenge)
|
||||||
logger.debug(self.sk)
|
log.debug(self.sk)
|
||||||
self._compute_cryptograms()
|
self._compute_cryptograms()
|
||||||
# verify computed cryptogram matches received cryptogram
|
# verify computed cryptogram matches received cryptogram
|
||||||
if self.card_cryptogram != resp['card_cryptogram']:
|
if self.card_cryptogram != resp['card_cryptogram']:
|
||||||
@@ -527,7 +568,7 @@ class SCP03(SCP):
|
|||||||
|
|
||||||
def _wrap_cmd_apdu(self, apdu: bytes, skip_cenc: bool = False) -> bytes:
|
def _wrap_cmd_apdu(self, apdu: bytes, skip_cenc: bool = False) -> bytes:
|
||||||
"""Wrap Command APDU for SCP03: calculate MAC and encrypt."""
|
"""Wrap Command APDU for SCP03: calculate MAC and encrypt."""
|
||||||
logger.debug("wrap_cmd_apdu(%s)", b2h(apdu))
|
log.debug("wrap_cmd_apdu(%s)", b2h(apdu))
|
||||||
|
|
||||||
if not self.do_cmac:
|
if not self.do_cmac:
|
||||||
return apdu
|
return apdu
|
||||||
@@ -582,7 +623,7 @@ class SCP03(SCP):
|
|||||||
# status word: in this case only the status word shall be returned in the response. All status words
|
# status word: in this case only the status word shall be returned in the response. All status words
|
||||||
# except '9000' and warning status words (i.e. '62xx' and '63xx') shall be interpreted as error status
|
# except '9000' and warning status words (i.e. '62xx' and '63xx') shall be interpreted as error status
|
||||||
# words.
|
# words.
|
||||||
logger.debug("unwrap_rsp_apdu(sw=%s, rsp_apdu=%s)", sw, rsp_apdu)
|
log.debug("unwrap_rsp_apdu(sw=%s, rsp_apdu=%s)", sw, rsp_apdu)
|
||||||
if not self.do_rmac:
|
if not self.do_rmac:
|
||||||
assert not self.do_renc
|
assert not self.do_renc
|
||||||
return rsp_apdu
|
return rsp_apdu
|
||||||
@@ -598,9 +639,9 @@ class SCP03(SCP):
|
|||||||
if self.do_renc:
|
if self.do_renc:
|
||||||
# decrypt response data
|
# decrypt response data
|
||||||
decrypted = self.sk._decrypt(response_data)
|
decrypted = self.sk._decrypt(response_data)
|
||||||
logger.debug("decrypted: %s", b2h(decrypted))
|
log.debug("decrypted: %s", b2h(decrypted))
|
||||||
# remove padding
|
# remove padding
|
||||||
response_data = unpad80(decrypted)
|
response_data = unpad80(decrypted)
|
||||||
logger.debug("response_data: %s", b2h(response_data))
|
log.debug("response_data: %s", b2h(response_data))
|
||||||
|
|
||||||
return response_data
|
return response_data
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class UiccSdInstallParams(TLV_IE_Collection, nested=[UiccScp, AcceptExtradAppsAn
|
|||||||
|
|
||||||
# Key Usage:
|
# Key Usage:
|
||||||
# KVN 0x01 .. 0x0F reserved for SCP80
|
# KVN 0x01 .. 0x0F reserved for SCP80
|
||||||
|
# KVN 0x81 .. 0x8f reserved for SCP81
|
||||||
# KVN 0x11 reserved for DAP specified in ETSI TS 102 226
|
# KVN 0x11 reserved for DAP specified in ETSI TS 102 226
|
||||||
# KVN 0x20 .. 0x2F reserved for SCP02
|
# KVN 0x20 .. 0x2F reserved for SCP02
|
||||||
# KID 0x01 = ENC; 0x02 = MAC; 0x03 = DEK
|
# KID 0x01 = ENC; 0x02 = MAC; 0x03 = DEK
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# JavaCard related utilities
|
# JavaCard related utilities
|
||||||
#
|
#
|
||||||
# (C) 2024 by Sysmocom s.f.m.c. GmbH
|
# (C) 2024 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ class SimCard(SimCardBase):
|
|||||||
return sw
|
return sw
|
||||||
|
|
||||||
def update_smsp(self, smsp):
|
def update_smsp(self, smsp):
|
||||||
data, sw = self._scc.update_record(EF['SMSP'], 1, rpad(smsp, 84))
|
print("using update_smsp")
|
||||||
|
data, sw = self._scc.update_record(EF['SMSP'], 1, smsp, leftpad=True)
|
||||||
return sw
|
return sw
|
||||||
|
|
||||||
def update_ad(self, mnc=None, opmode=None, ofm=None, path=EF['AD']):
|
def update_ad(self, mnc=None, opmode=None, ofm=None, path=EF['AD']):
|
||||||
|
|||||||
+27
-5
@@ -4,7 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
#
|
#
|
||||||
# (C) 2025 by Sysmocom s.f.m.c. GmbH
|
# (C) 2025 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# Author: Philipp Maier <pmaier@sysmocom.de>
|
# Author: Philipp Maier <pmaier@sysmocom.de>
|
||||||
@@ -24,7 +24,16 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from cmd2 import style
|
import enum
|
||||||
|
import cmd2
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
if version.parse(cmd2.__version__) >= version.parse("3.0.0"):
|
||||||
|
from cmd2 import stylize as _stylize # pylint: disable=no-name-in-module
|
||||||
|
def _style(text, fg=None): # pylint: disable=function-redefined
|
||||||
|
return _stylize(text, fg) if fg else text
|
||||||
|
else: # cmd2>=2.6.2
|
||||||
|
from cmd2 import style as _style # pylint: disable=no-name-in-module
|
||||||
|
|
||||||
class _PySimLogHandler(logging.Handler):
|
class _PySimLogHandler(logging.Handler):
|
||||||
def __init__(self, log_callback):
|
def __init__(self, log_callback):
|
||||||
@@ -44,7 +53,7 @@ class PySimLogger:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
LOG_FMTSTR = "%(levelname)s: %(message)s"
|
LOG_FMTSTR = "%(levelname)s: %(message)s"
|
||||||
LOG_FMTSTR_VERBOSE = "%(module)s.%(lineno)d -- %(name)s - " + LOG_FMTSTR
|
LOG_FMTSTR_VERBOSE = "%(name)s.%(lineno)d -- " + LOG_FMTSTR
|
||||||
__formatter = logging.Formatter(LOG_FMTSTR)
|
__formatter = logging.Formatter(LOG_FMTSTR)
|
||||||
__formatter_verbose = logging.Formatter(LOG_FMTSTR_VERBOSE)
|
__formatter_verbose = logging.Formatter(LOG_FMTSTR_VERBOSE)
|
||||||
|
|
||||||
@@ -63,7 +72,7 @@ class PySimLogger:
|
|||||||
raise RuntimeError('static class, do not instantiate')
|
raise RuntimeError('static class, do not instantiate')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def setup(print_callback = None, colors:dict = {}):
|
def setup(print_callback = None, colors:dict = {}, verbose_debug:bool = False):
|
||||||
"""
|
"""
|
||||||
Set a print callback function and color scheme. This function call is optional. In case this method is not
|
Set a print callback function and color scheme. This function call is optional. In case this method is not
|
||||||
called, default settings apply.
|
called, default settings apply.
|
||||||
@@ -72,10 +81,20 @@ class PySimLogger:
|
|||||||
have the following format: print_callback(message:str)
|
have the following format: print_callback(message:str)
|
||||||
colors : An optional dict through which certain log levels can be assigned a color.
|
colors : An optional dict through which certain log levels can be assigned a color.
|
||||||
(e.g. {logging.WARN: YELLOW})
|
(e.g. {logging.WARN: YELLOW})
|
||||||
|
verbose_debug: Enable verbose logging and set the loglevel DEBUG when set to true. Otherwise the
|
||||||
|
non-verbose logging is used and the loglevel is set to INFO. This setting can be changed
|
||||||
|
using the set_verbose and set_level methods at any time.
|
||||||
"""
|
"""
|
||||||
PySimLogger.print_callback = print_callback
|
PySimLogger.print_callback = print_callback
|
||||||
PySimLogger.colors = colors
|
PySimLogger.colors = colors
|
||||||
|
|
||||||
|
if (verbose_debug):
|
||||||
|
PySimLogger.set_verbose(True)
|
||||||
|
PySimLogger.set_level(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
PySimLogger.set_verbose(False)
|
||||||
|
PySimLogger.set_level(logging.INFO)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_verbose(verbose:bool = False):
|
def set_verbose(verbose:bool = False):
|
||||||
"""
|
"""
|
||||||
@@ -108,7 +127,10 @@ class PySimLogger:
|
|||||||
formatted_message = logging.Formatter.format(PySimLogger.__formatter, record)
|
formatted_message = logging.Formatter.format(PySimLogger.__formatter, record)
|
||||||
color = PySimLogger.colors.get(record.levelno)
|
color = PySimLogger.colors.get(record.levelno)
|
||||||
if color:
|
if color:
|
||||||
PySimLogger.print_callback(style(formatted_message, fg = color))
|
if isinstance(color, str) and not isinstance(color, enum.Enum):
|
||||||
|
PySimLogger.print_callback(color + formatted_message + "\033[0m")
|
||||||
|
else:
|
||||||
|
PySimLogger.print_callback(_style(formatted_message, fg = color))
|
||||||
else:
|
else:
|
||||||
PySimLogger.print_callback(formatted_message)
|
PySimLogger.print_callback(formatted_message)
|
||||||
|
|
||||||
|
|||||||
+253
-16
@@ -18,10 +18,12 @@
|
|||||||
import zlib
|
import zlib
|
||||||
import abc
|
import abc
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple, List, Union
|
||||||
from construct import Enum, Int8ub, Int16ub, Struct, BitsInteger, BitStruct
|
from construct import ConstructError, Enum, Int8ub, Int16ub, Struct, BitsInteger, BitStruct
|
||||||
from construct import Flag, Padding, Switch, this, PrefixedArray, GreedyRange
|
from construct import Flag, Padding, Switch, this, PrefixedArray, GreedyRange
|
||||||
|
from construct import Const, Prefixed, Select, Construct, SizeofError, stream_read, stream_write
|
||||||
from osmocom.construct import *
|
from osmocom.construct import *
|
||||||
|
from osmocom.tlv import bertlv_encode_len
|
||||||
from osmocom.utils import b2h
|
from osmocom.utils import b2h
|
||||||
|
|
||||||
from pySim.sms import UserDataHeader
|
from pySim.sms import UserDataHeader
|
||||||
@@ -56,13 +58,225 @@ CompactRemoteResp = Struct('number_of_commands'/Int8ub,
|
|||||||
'last_status_word'/HexAdapter(Bytes(2)),
|
'last_status_word'/HexAdapter(Bytes(2)),
|
||||||
'last_response_data'/HexAdapter(GreedyBytes))
|
'last_response_data'/HexAdapter(GreedyBytes))
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
# Expanded Remote Application data format, ETSI TS 102 226 V19.0.0 (2025-11) Section 5.2
|
||||||
|
# 5.2.1 Expanded Remote command structure
|
||||||
|
# 5.2.1.1 C-APDU TLV
|
||||||
|
# 5.2.1.2 Immediate Action TLV
|
||||||
|
# 5.2.1.3 Error Action TLV
|
||||||
|
# 5.2.1.4 Script Chaining TLV
|
||||||
|
# 5.2.2 Expanded Remote response structure (tables 5.10 .. 5.16)
|
||||||
|
#
|
||||||
|
# definite length coding and indefinite length coding are supported.
|
||||||
|
#
|
||||||
|
# BER-TLV tag values from ETSI TS 101 220 V19.0.0 tables 7.18, 7.19, 7.20
|
||||||
|
# C-APDU / R-APDU ETSI TS 102 223 Section 8.35 + 8.36
|
||||||
|
# inside these the CR flag of the tag is 0 (TS 101 220 tables 7.19/7.20),
|
||||||
|
# so tag bytes are 22 and 23 and not A2/A3.
|
||||||
|
#
|
||||||
|
# This layer sits above the TS 102 225 security layer.
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
class BerTlvLength(Construct):
|
||||||
|
"""A definite-length BER-TLV length field used by the "expanded remote
|
||||||
|
application data format" from ISO/IEC 8825-1 referenced by TS 102 226 5.2
|
||||||
|
|
||||||
|
- short form (0..127 -> single octet)
|
||||||
|
- long form (128.. -> 0x8N followed by N length octets)
|
||||||
|
Indefinite length coding (first octet 0x80, TS 102 226 tables 5.2a/5.10a)
|
||||||
|
is omitted here because it is only recommended for HTTPS/CoAP transport, not SMS."""
|
||||||
|
def _parse(self, stream, context, path):
|
||||||
|
first = stream_read(stream, 1, path)[0]
|
||||||
|
if first < 0x80:
|
||||||
|
return first
|
||||||
|
num_octets = first & 0x7f
|
||||||
|
if num_octets == 0:
|
||||||
|
raise NotImplementedError('indefinite coding is not supported')
|
||||||
|
return int.from_bytes(stream_read(stream, num_octets, path), 'big')
|
||||||
|
|
||||||
|
def _build(self, obj, stream, context, path):
|
||||||
|
encoded = bertlv_encode_len(obj)
|
||||||
|
stream_write(stream, encoded, len(encoded), path)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _sizeof(self, context, path):
|
||||||
|
raise SizeofError('BER-TLV length has a variable size?!')
|
||||||
|
|
||||||
|
BerTlvLen = BerTlvLength()
|
||||||
|
|
||||||
|
class _RApduValueAdapter(Adapter):
|
||||||
|
"""Split/join value of R-APDU COMPREHENSION-TLV TS 102 223 8.36
|
||||||
|
[R-APDU data (x-2 bytes)] SW1 SW2."""
|
||||||
|
def _decode(self, obj, context, path):
|
||||||
|
raw = bytes(obj)
|
||||||
|
return Container(response_data=b2h(raw[:-2]), status_word=b2h(raw[-2:]))
|
||||||
|
|
||||||
|
def _encode(self, obj, context, path):
|
||||||
|
return h2b(obj['response_data']) + h2b(obj['status_word'])
|
||||||
|
|
||||||
|
#### Command Scripting template TS 102 226 tables 5.2 / 5.2a, TS 101 220 tables 7.18/7.19
|
||||||
|
#
|
||||||
|
# The two TS 101 220 table 7.18 length codings use different template tags:
|
||||||
|
# - definite tag AA
|
||||||
|
# - indefinite AE
|
||||||
|
# In both codings the inner Command TLVs use definite length coding, only the
|
||||||
|
# surrounding template differs.
|
||||||
|
|
||||||
|
# TS 102 223 8.35
|
||||||
|
ExpandedC_APDU = Struct('_tag'/Const(b'\x22'),
|
||||||
|
'c_apdu'/Prefixed(BerTlvLen, HexAdapter(GreedyBytes)))
|
||||||
|
|
||||||
|
# shared by both length codings.
|
||||||
|
ExpandedCmdItems = GreedyRange(ExpandedC_APDU)
|
||||||
|
|
||||||
|
# TS 102 226 table 5.2: Command Scripting template, definite length coding only
|
||||||
|
ExpandedCmd = Struct('_tag'/Const(b'\xaa'),
|
||||||
|
'commands'/Prefixed(BerTlvLen, ExpandedCmdItems))
|
||||||
|
|
||||||
|
# TS 102 226 table 5.2a: indefinite length coding, 'AE 80 <C-APDU TLVs> 00 00'. GreedyRange
|
||||||
|
# stops at the first octet that is not a C-APDU tag, which is the end-of-contents marker.
|
||||||
|
ExpandedCmdIndef = Struct('_tag'/Const(b'\xae'), '_indef'/Const(b'\x80'),
|
||||||
|
'commands'/ExpandedCmdItems, '_eoc'/Const(b'\x00\x00'))
|
||||||
|
|
||||||
|
#### Response Scripting template TS 102 226 5.2.2, tables 5.10-5.16, TS 101 220 table 7.20
|
||||||
|
|
||||||
|
# TS 102 223 8.36
|
||||||
|
ExpandedR_APDU = Struct('_tag'/Const(b'\x23'),
|
||||||
|
'r_apdu'/Prefixed(BerTlvLen, _RApduValueAdapter(GreedyBytes)))
|
||||||
|
|
||||||
|
# TS 102 226 table 5.11
|
||||||
|
# Value is an integer per ISO/IEC 8825-1, likely just one octet.
|
||||||
|
ExpandedNumExecuted = Struct('_tag'/Const(b'\x80'),
|
||||||
|
'number_of_commands'/Prefixed(BerTlvLen, GreedyInteger()))
|
||||||
|
|
||||||
|
# TS 102 226 table 5.12
|
||||||
|
ExpandedBadFormat = Struct('_tag'/Const(b'\x90'),
|
||||||
|
'bad_format'/Prefixed(BerTlvLen,
|
||||||
|
Enum(Int8ub, unknown_tag=1, wrong_length=2, length_not_found=3)))
|
||||||
|
|
||||||
|
# TS 102 226 table 5.14
|
||||||
|
ExpandedImmediateActionResp = Struct('_tag'/Const(b'\x81'),
|
||||||
|
'immediate_action_response'/Prefixed(BerTlvLen,
|
||||||
|
Enum(Int8ub, suspension_error=1)))
|
||||||
|
|
||||||
|
# TS 102 226 table 5.16
|
||||||
|
ExpandedScriptChainingResp = Struct('_tag'/Const(b'\x83'),
|
||||||
|
'script_chaining_response'/Prefixed(BerTlvLen,
|
||||||
|
Enum(Int8ub, no_previous_script=1,
|
||||||
|
not_supported=2, unable_to_process=3)))
|
||||||
|
|
||||||
|
# response TLVs shared by the def and indef Response Scripting templates
|
||||||
|
ExpandedRespItems = GreedyRange(Select(ExpandedR_APDU,
|
||||||
|
ExpandedBadFormat,
|
||||||
|
ExpandedImmediateActionResp,
|
||||||
|
ExpandedScriptChainingResp))
|
||||||
|
|
||||||
|
# - starts with the "Number of executed command TLV objects" (table 5.10/5.13/5.15)
|
||||||
|
# - followed by a sequence of R-APDU TLVs
|
||||||
|
# - and/or one of the error # response TLVs
|
||||||
|
ExpandedRemoteResp = Struct('_tag'/Const(b'\xab'),
|
||||||
|
'body'/Prefixed(BerTlvLen, Struct(
|
||||||
|
'num_executed'/ExpandedNumExecuted,
|
||||||
|
'responses'/ExpandedRespItems)))
|
||||||
|
|
||||||
|
# TS 102 226 table 5.10a: indefinite length coding, no "number of executed" TLV
|
||||||
|
ExpandedRemoteRespIndef = Struct('_tag'/Const(b'\xaf'), '_indef'/Const(b'\x80'),
|
||||||
|
'responses'/ExpandedRespItems, '_eoc'/Const(b'\x00\x00'))
|
||||||
|
|
||||||
|
|
||||||
|
def encode_expanded_cmd(apdus: Union[bytes, List[bytes]],
|
||||||
|
length_coding: str = 'definite') -> bytes:
|
||||||
|
"""builds the Command Scripting template, TS 102 226 5.2.1
|
||||||
|
|
||||||
|
Args:
|
||||||
|
apdus: single C-APDU bytes or list of C-APDUs bytes. Each
|
||||||
|
C-APDU is wrapped into a C-APDU TLV- This function does not add
|
||||||
|
or modify Le.
|
||||||
|
length_coding: 'definite' (the default, tag 'AA', table 5.2) or
|
||||||
|
'indefinite' (tag 'AE', table 5.2a: 'AE 80 <cmd TLVs> 00 00').
|
||||||
|
Inner C-APDU TLVs use definite length coding in both cases.
|
||||||
|
Returns:
|
||||||
|
encoded Command Scripting template as bytes
|
||||||
|
"""
|
||||||
|
if isinstance(apdus, (bytes, bytearray)):
|
||||||
|
apdus = [apdus]
|
||||||
|
commands = [{'c_apdu': b2h(a)} for a in apdus]
|
||||||
|
if length_coding == 'definite':
|
||||||
|
return ExpandedCmd.build({'commands': commands})
|
||||||
|
if length_coding == 'indefinite':
|
||||||
|
return ExpandedCmdIndef.build({'commands': commands})
|
||||||
|
raise ValueError("Invalid length_coding: %r" % length_coding)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_expanded_resp(data: bytes) -> Container:
|
||||||
|
"""Decode a Response Scripting template, TS 102 226 5.2.2 def and indef length
|
||||||
|
coding
|
||||||
|
|
||||||
|
returned Container has:
|
||||||
|
number_of_commands -- "number of executed command TLV objects" table 5.11
|
||||||
|
for definite coding. indefinite coding does not have
|
||||||
|
this TLV, so report the number of returned R-APDUs instead.
|
||||||
|
commands -- list of Containers, one per R-APDU TLV, each
|
||||||
|
with 'response_data' and 'status_word' hexstr
|
||||||
|
last_response_data -- response_data of the last R-APDU or ''
|
||||||
|
last_status_word -- status_word of the last R-APDU or None
|
||||||
|
truncated -- True if any R-APDU has SW 62F1.
|
||||||
|
5.2.1.1 states card sets that status when it had to truncate
|
||||||
|
C-APDU response data, and "this shall terminate the
|
||||||
|
processing of the command list".
|
||||||
|
so the response is short AND the remaining commands never ran.
|
||||||
|
bad_format -- error type of a trailing Bad format TLV if present
|
||||||
|
immediate_action_response -- Immediate Action Response TLV, if there was a suspension error
|
||||||
|
script_chaining_response -- Script Chaining Response TLV, if there was a chaining error
|
||||||
|
|
||||||
|
The 'last_response_data'/'last_status_word'/'number_of_commands' keys are compatible with
|
||||||
|
CompactRemoteResp so existing callers keep working."""
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = h2b(data)
|
||||||
|
try:
|
||||||
|
if data[:1] == b'\xaf':
|
||||||
|
responses = ExpandedRemoteRespIndef.parse(data)['responses']
|
||||||
|
num_executed = None
|
||||||
|
else:
|
||||||
|
parsed = ExpandedRemoteResp.parse(data)
|
||||||
|
responses = parsed['body']['responses']
|
||||||
|
num_executed = parsed['body']['num_executed']['number_of_commands']
|
||||||
|
except ConstructError as e:
|
||||||
|
raise ValueError('malformed Response Scripting template: %s' % e) from e
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
bad_format = None
|
||||||
|
immediate_action_response = None
|
||||||
|
script_chaining_response = None
|
||||||
|
for item in responses:
|
||||||
|
if 'r_apdu' in item:
|
||||||
|
commands.append(Container(response_data=item['r_apdu']['response_data'],
|
||||||
|
status_word=item['r_apdu']['status_word']))
|
||||||
|
elif 'bad_format' in item:
|
||||||
|
bad_format = item['bad_format']
|
||||||
|
elif 'immediate_action_response' in item:
|
||||||
|
immediate_action_response = item['immediate_action_response']
|
||||||
|
elif 'script_chaining_response' in item:
|
||||||
|
script_chaining_response = item['script_chaining_response']
|
||||||
|
# TS 102 226 5.2.1.1: 62F1 means response of a C-APDU was truncated, processing terminated
|
||||||
|
truncated = any(c['status_word'].lower() == '62f1' for c in commands)
|
||||||
|
return Container(number_of_commands=num_executed if num_executed is not None else len(commands),
|
||||||
|
commands=commands,
|
||||||
|
last_response_data=commands[-1]['response_data'] if commands else '',
|
||||||
|
last_status_word=commands[-1]['status_word'] if commands else None,
|
||||||
|
truncated=truncated,
|
||||||
|
bad_format=bad_format,
|
||||||
|
immediate_action_response=immediate_action_response,
|
||||||
|
script_chaining_response=script_chaining_response)
|
||||||
|
|
||||||
RC_CC_DS = Enum(BitsInteger(2), no_rc_cc_ds=0, rc=1, cc=2, ds=3)
|
RC_CC_DS = Enum(BitsInteger(2), no_rc_cc_ds=0, rc=1, cc=2, ds=3)
|
||||||
|
CNTR_REQ = Enum(BitsInteger(2), no_counter=0, counter_no_replay_or_seq=1, counter_must_be_higher=2, counter_must_be_lower=3)
|
||||||
|
POR_REQ = Enum(BitsInteger(2), no_por=0, por_required=1, por_only_when_error=2)
|
||||||
|
|
||||||
# TS 102 225 Section 5.1.1 + TS 31.115 Section 4.2
|
# TS 102 225 Section 5.1.1 + TS 31.115 Section 4.2
|
||||||
SPI = BitStruct( # first octet
|
SPI = BitStruct( # first octet
|
||||||
Padding(3),
|
Padding(3),
|
||||||
'counter'/Enum(BitsInteger(2), no_counter=0, counter_no_replay_or_seq=1,
|
'counter'/CNTR_REQ,
|
||||||
counter_must_be_higher=2, counter_must_be_lower=3),
|
|
||||||
'ciphering'/Flag,
|
'ciphering'/Flag,
|
||||||
'rc_cc_ds'/RC_CC_DS,
|
'rc_cc_ds'/RC_CC_DS,
|
||||||
# second octet
|
# second octet
|
||||||
@@ -70,8 +284,7 @@ SPI = BitStruct( # first octet
|
|||||||
'por_in_submit'/Flag,
|
'por_in_submit'/Flag,
|
||||||
'por_shall_be_ciphered'/Flag,
|
'por_shall_be_ciphered'/Flag,
|
||||||
'por_rc_cc_ds'/RC_CC_DS,
|
'por_rc_cc_ds'/RC_CC_DS,
|
||||||
'por'/Enum(BitsInteger(2), no_por=0,
|
'por'/POR_REQ
|
||||||
por_required=1, por_only_when_error=2)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# TS 102 225 Section 5.1.2
|
# TS 102 225 Section 5.1.2
|
||||||
@@ -149,13 +362,23 @@ class OtaDialect(abc.ABC):
|
|||||||
raise ValueError("Invalid rc_cc_ds: %s" % spi['rc_cc_ds'])
|
raise ValueError("Invalid rc_cc_ds: %s" % spi['rc_cc_ds'])
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict, apdu: bytes) -> bytes:
|
def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict,
|
||||||
|
apdu: Union[bytes, List[bytes]], remote_format: str = 'compact') -> bytes:
|
||||||
|
"""Encode a command for a format.
|
||||||
|
|
||||||
|
remote_format:
|
||||||
|
'compact' TS 102 226 5.1, DEFAULT assumes apdus are opaque already-concatenated command strings
|
||||||
|
'expanded' TS 102 226 5.2 wraps a single C-APDU or list of C-APDUs in a Command Scripting template."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def decode_resp(self, otak: OtaKeyset, spi: dict, apdu: bytes) -> (object, Optional["CompactRemoteResp"]):
|
def decode_resp(self, otak: OtaKeyset, spi: dict, apdu: bytes,
|
||||||
"""Decode a response into a response packet and, if indicted (by a
|
remote_format: str = 'compact') -> (object, Optional[object]):
|
||||||
response status of `"por_ok"`) a decoded response.
|
"""Decode response into response packet + a decoded response if por_ok.
|
||||||
|
|
||||||
|
remote_format:
|
||||||
|
'compact' -> DEFAULT TS 102 226 5.1.2 CompactRemoteResp2
|
||||||
|
'expanded' -> container returned by decode_expanded_resp(), TS 102 226 5.2.2
|
||||||
|
|
||||||
The response packet's common characteristics are not fully determined,
|
The response packet's common characteristics are not fully determined,
|
||||||
and (so far) completely proprietary per dialect."""
|
and (so far) completely proprietary per dialect."""
|
||||||
@@ -221,12 +444,12 @@ class OtaAlgoCrypt(OtaAlgo, abc.ABC):
|
|||||||
for subc in cls.__subclasses__():
|
for subc in cls.__subclasses__():
|
||||||
if subc.enum_name == otak.algo_crypt:
|
if subc.enum_name == otak.algo_crypt:
|
||||||
return subc(otak)
|
return subc(otak)
|
||||||
raise ValueError('No implementation for crypt algorithm %s' % otak.algo_auth)
|
raise ValueError('No implementation for crypt algorithm %s' % otak.algo_crypt)
|
||||||
|
|
||||||
class OtaAlgoAuth(OtaAlgo, abc.ABC):
|
class OtaAlgoAuth(OtaAlgo, abc.ABC):
|
||||||
def __init__(self, otak: OtaKeyset):
|
def __init__(self, otak: OtaKeyset):
|
||||||
if self.enum_name != otak.algo_auth:
|
if self.enum_name != otak.algo_auth:
|
||||||
raise ValueError('Cannot use algorithm %s with key for %s' % (self.enum_name, otak.algo_crypt))
|
raise ValueError('Cannot use algorithm %s with key for %s' % (self.enum_name, otak.algo_auth))
|
||||||
super().__init__(otak)
|
super().__init__(otak)
|
||||||
|
|
||||||
def sign(self, data:bytes) -> bytes:
|
def sign(self, data:bytes) -> bytes:
|
||||||
@@ -335,7 +558,16 @@ class OtaDialectSms(OtaDialect):
|
|||||||
'secured_data'/GreedyBytes)
|
'secured_data'/GreedyBytes)
|
||||||
hdr_construct = Struct('chl'/Int8ub, 'spi'/SPI, 'kic'/KIC, 'kid'/KID_CC, 'tar'/Bytes(3))
|
hdr_construct = Struct('chl'/Int8ub, 'spi'/SPI, 'kic'/KIC, 'kid'/KID_CC, 'tar'/Bytes(3))
|
||||||
|
|
||||||
def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict, apdu: bytes) -> bytes:
|
def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict,
|
||||||
|
apdu: Union[bytes, List[bytes]], remote_format: str = 'compact') -> bytes:
|
||||||
|
# as above:
|
||||||
|
# expanded format is a Command Scripting template wrapping the C-APDU(s)
|
||||||
|
# compact format passes already concatenated command string
|
||||||
|
if remote_format == 'expanded':
|
||||||
|
apdu = encode_expanded_cmd(apdu)
|
||||||
|
elif remote_format != 'compact':
|
||||||
|
raise ValueError("Invalid remote_format: %s" % remote_format)
|
||||||
|
|
||||||
# length of signature in octets
|
# length of signature in octets
|
||||||
len_sig = self._compute_sig_len(spi)
|
len_sig = self._compute_sig_len(spi)
|
||||||
pad_cnt = 0
|
pad_cnt = 0
|
||||||
@@ -446,7 +678,10 @@ class OtaDialectSms(OtaDialect):
|
|||||||
return hdr_dec['tar'], spi, apdu
|
return hdr_dec['tar'], spi, apdu
|
||||||
|
|
||||||
|
|
||||||
def decode_resp(self, otak: OtaKeyset, spi: dict, data: bytes) -> ("OtaDialectSms.SmsResponsePacket", Optional["CompactRemoteResp"]):
|
def decode_resp(self, otak: OtaKeyset, spi: dict, data: bytes,
|
||||||
|
remote_format: str = 'compact') -> ("OtaDialectSms.SmsResponsePacket", Optional[object]):
|
||||||
|
if remote_format not in ('compact', 'expanded'):
|
||||||
|
raise ValueError("Invalid remote_format: %s ?!" % remote_format)
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
data = h2b(data)
|
data = h2b(data)
|
||||||
# plain-text POR: 027100000e0ab000110000000000000001612f
|
# plain-text POR: 027100000e0ab000110000000000000001612f
|
||||||
@@ -492,9 +727,11 @@ class OtaDialectSms(OtaDialect):
|
|||||||
else:
|
else:
|
||||||
raise OtaCheckError('Unknown por_rc_cc_ds: %s' % spi['por_rc_cc_ds'])
|
raise OtaCheckError('Unknown por_rc_cc_ds: %s' % spi['por_rc_cc_ds'])
|
||||||
|
|
||||||
# TODO: ExpandedRemoteResponse according to TS 102 226 5.2.2
|
|
||||||
if res.response_status == 'por_ok' and len(res['secured_data']):
|
if res.response_status == 'por_ok' and len(res['secured_data']):
|
||||||
dec = CompactRemoteResp.parse(res['secured_data'])
|
if remote_format == 'expanded':
|
||||||
|
dec = decode_expanded_resp(res['secured_data'])
|
||||||
|
else:
|
||||||
|
dec = CompactRemoteResp.parse(res['secured_data'])
|
||||||
else:
|
else:
|
||||||
dec = None
|
dec = None
|
||||||
return (res, dec)
|
return (res, dec)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
#
|
#
|
||||||
# (C) 2021 by Sysmocom s.f.m.c. GmbH
|
# (C) 2021 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
|
|
||||||
"""Representation of the runtime state of an application like pySim-shell.
|
"""Representation of the runtime state of an application like pySim-shell.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ from pySim.exceptions import *
|
|||||||
from pySim.filesystem import *
|
from pySim.filesystem import *
|
||||||
from pySim.log import PySimLogger
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
log = PySimLogger.get("RUNTIME")
|
log = PySimLogger.get(__name__)
|
||||||
|
|
||||||
def lchan_nr_from_cla(cla: int) -> int:
|
def lchan_nr_from_cla(cla: int) -> int:
|
||||||
"""Resolve the logical channel number from the CLA byte."""
|
"""Resolve the logical channel number from the CLA byte."""
|
||||||
@@ -115,7 +116,7 @@ class RuntimeState:
|
|||||||
for a in aids_unknown:
|
for a in aids_unknown:
|
||||||
log.info(" unknown: %s (EF.DIR)" % a)
|
log.info(" unknown: %s (EF.DIR)" % a)
|
||||||
else:
|
else:
|
||||||
log.warn("EF.DIR seems to be empty!")
|
log.warning("EF.DIR seems to be empty!")
|
||||||
|
|
||||||
# Some card applications may not be registered in EF.DIR, we will actively
|
# Some card applications may not be registered in EF.DIR, we will actively
|
||||||
# probe for those applications
|
# probe for those applications
|
||||||
@@ -556,8 +557,8 @@ class RuntimeLchan:
|
|||||||
raise TypeError("Data length (%u) exceeds %s size (%u) by %u bytes" %
|
raise TypeError("Data length (%u) exceeds %s size (%u) by %u bytes" %
|
||||||
(data_len, writeable_name, writeable_size, data_len - writeable_size))
|
(data_len, writeable_name, writeable_size, data_len - writeable_size))
|
||||||
elif data_len < writeable_size:
|
elif data_len < writeable_size:
|
||||||
log.warn("Data length (%u) less than %s size (%u), leaving %u unwritten bytes at the end of the %s" %
|
log.warning("Data length (%u) less than %s size (%u), leaving %u unwritten bytes at the end of the %s" %
|
||||||
(data_len, writeable_name, writeable_size, writeable_size - data_len, writeable_name))
|
(data_len, writeable_name, writeable_size, writeable_size - data_len, writeable_name))
|
||||||
|
|
||||||
def update_binary(self, data_hex: str, offset: int = 0):
|
def update_binary(self, data_hex: str, offset: int = 0):
|
||||||
"""Update transparent EF binary data.
|
"""Update transparent EF binary data.
|
||||||
|
|||||||
+117
-5
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import typing
|
import typing
|
||||||
import abc
|
import abc
|
||||||
|
import logging
|
||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
from construct import Int8ub, Byte, Bit, Flag, BitsInteger
|
from construct import Int8ub, Byte, Bit, Flag, BitsInteger
|
||||||
from construct import Struct, Enum, Tell, BitStruct, this, Padding
|
from construct import Struct, Enum, Tell, BitStruct, this, Padding
|
||||||
@@ -28,6 +29,8 @@ from osmocom.utils import Hexstr, h2b, b2h
|
|||||||
|
|
||||||
from smpp.pdu import pdu_types, operations
|
from smpp.pdu import pdu_types, operations
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BytesOrHex = typing.Union[Hexstr, bytes]
|
BytesOrHex = typing.Union[Hexstr, bytes]
|
||||||
|
|
||||||
class UserDataHeader:
|
class UserDataHeader:
|
||||||
@@ -60,6 +63,109 @@ class UserDataHeader:
|
|||||||
return self._construct.build({'ies':self.ies, 'data':b''})
|
return self._construct.build({'ies':self.ies, 'data':b''})
|
||||||
|
|
||||||
|
|
||||||
|
class ConcatenatedSmsReassembler:
|
||||||
|
"""3GPP TS 23.040 section 9.2.3.24 concat multi part reassembly
|
||||||
|
|
||||||
|
A large user-data payload (e.g. a big OTA response packet) is split by the
|
||||||
|
sending entity into several SMS,
|
||||||
|
each carries a
|
||||||
|
- "concat short messages" IE in its UDH that identifies the set (ref num),
|
||||||
|
- total number of parts
|
||||||
|
- this parts seqno.
|
||||||
|
supports both:
|
||||||
|
IEI 0x00, section 9.2.3.24.1 8-bit ref form
|
||||||
|
IEI 0x08, section 9.2.3.24.8 the 16-bit ref form
|
||||||
|
|
||||||
|
Feed each received TP-User-Data (UDH + payload) to add() which
|
||||||
|
returns the reassembled TP-User-Data once all parts of the set have arrived,
|
||||||
|
or None as long as parts are still missing.
|
||||||
|
|
||||||
|
A non-concatenated SMS is returned unchanged,
|
||||||
|
just like one where the concat IE holds a reserved value:
|
||||||
|
TS 23.040 9.2.3.24.1 says
|
||||||
|
- both a total of zero
|
||||||
|
- a sequence number that is zero or greater than the total
|
||||||
|
that "the receiving entity shall ignore the whole IE",
|
||||||
|
we treat the message as a single, non-concatenated one and warn, not
|
||||||
|
as an error, so the caller does not die.
|
||||||
|
|
||||||
|
The reassembled TP-User-Data is built with a UDH that contains
|
||||||
|
the non-concat IEs seen in the parts, for example the the OTA "response packet"
|
||||||
|
indicator IE 0x71, followed by the concatenated payloads in sequence order,
|
||||||
|
so exactly the single-SMS form the sender would have produced for a payload that fits
|
||||||
|
into one SMS.
|
||||||
|
This allows convenient decoding by the normal single part path."""
|
||||||
|
|
||||||
|
# IEI: Concatenated short messages, 8-bit reference number
|
||||||
|
# (see 3GPP TS 23.040 section 9.2.3.24 and section 9.2.3.24.1)
|
||||||
|
CONCAT_8BIT = 0x00
|
||||||
|
# IEI: Concatenated short message, 16-bit reference number
|
||||||
|
# (see 3GPP TS 23.040 section 9.2.3.24 and section 9.2.3.24.8)
|
||||||
|
CONCAT_16BIT = 0x08
|
||||||
|
|
||||||
|
def __init__(self, max_sets: int = 8):
|
||||||
|
# keyed by (iei, ref, total): {'parts': {seq: payload}, 'header_ies'}, insertion ordered
|
||||||
|
self.sets = {}
|
||||||
|
self.max_sets = max_sets # incomplete sets kept, oldest is dropped beyond that
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_concat_ie(cls, ies) -> typing.Optional[typing.Tuple[int, int, int, int]]:
|
||||||
|
"""Return (iei, ref, total, seq) of the concat IE, or None"""
|
||||||
|
for ie in ies:
|
||||||
|
if ie['iei'] == cls.CONCAT_8BIT and ie['length'] == 3:
|
||||||
|
v = ie['value']
|
||||||
|
return cls.CONCAT_8BIT, v[0], v[1], v[2]
|
||||||
|
if ie['iei'] == cls.CONCAT_16BIT and ie['length'] == 4:
|
||||||
|
v = ie['value']
|
||||||
|
return cls.CONCAT_16BIT, int.from_bytes(v[0:2], 'big'), v[2], v[3]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add(self, tpud: BytesOrHex) -> typing.Optional[bytes]:
|
||||||
|
"""Add one TP-User-Data.
|
||||||
|
Returns
|
||||||
|
- the reassembled TP-User-Data if set is complete or sms not multipart,
|
||||||
|
- else None"""
|
||||||
|
if isinstance(tpud, str):
|
||||||
|
tpud = h2b(tpud)
|
||||||
|
udh, payload = UserDataHeader.from_bytes(tpud)
|
||||||
|
concat = self._parse_concat_ie(udh.ies)
|
||||||
|
if concat is None:
|
||||||
|
return tpud
|
||||||
|
iei, ref, total, seq = concat
|
||||||
|
if total < 1 or seq < 1 or seq > total:
|
||||||
|
# TS 23.040 9.2.3.24.1 / 9.2.3.24.8, total zero or seqno zero / > total:
|
||||||
|
# Ignoring the IE means the message has no valid concat IE, which is a single part message.
|
||||||
|
# Better warn and hand it back rather than raise, so we don't kill the callers receive loop/session
|
||||||
|
logger.warning('Ignoring reserved concat IE (ref=%u total=%u seq=%u), treating the '
|
||||||
|
'message as non-concat', ref, total, seq)
|
||||||
|
return tpud
|
||||||
|
# TS 23.040 9.2.3.24.1 Total is constant in a set, refno only unique per IE form -> both set identity
|
||||||
|
# - full count = seqno 1..total is present
|
||||||
|
# - part disagreeing on the total ends up as set that cannot complete like set with missing parts
|
||||||
|
key = (iei, ref, total)
|
||||||
|
if key not in self.sets and len(self.sets) >= self.max_sets:
|
||||||
|
del self.sets[next(iter(self.sets))]
|
||||||
|
s = self.sets.setdefault(key, {'parts': {}, 'header_ies': []})
|
||||||
|
s['parts'][seq] = payload
|
||||||
|
# - remember the non concat IEs (OTA 0x71 indicator for example)
|
||||||
|
# - keep first seen occurrence of each IEI,
|
||||||
|
# so app IE present only in the first segment is preserved independent of arrival order
|
||||||
|
seen = {ie['iei'] for ie in s['header_ies']}
|
||||||
|
for ie in udh.ies:
|
||||||
|
if ie['iei'] in (self.CONCAT_8BIT, self.CONCAT_16BIT):
|
||||||
|
continue
|
||||||
|
if ie['iei'] not in seen:
|
||||||
|
s['header_ies'].append(ie)
|
||||||
|
seen.add(ie['iei'])
|
||||||
|
if len(s['parts']) < total:
|
||||||
|
return None
|
||||||
|
# all parts present -> reassemble in seq order
|
||||||
|
del self.sets[(iei, ref, total)]
|
||||||
|
body = b''.join(s['parts'][i] for i in range(1, total + 1))
|
||||||
|
header = UserDataHeader(s['header_ies']).to_bytes()
|
||||||
|
return header + body
|
||||||
|
|
||||||
|
|
||||||
def smpp_dcs_is_8bit(dcs: pdu_types.DataCoding) -> bool:
|
def smpp_dcs_is_8bit(dcs: pdu_types.DataCoding) -> bool:
|
||||||
"""Determine if the given SMPP data coding scheme is 8-bit or not."""
|
"""Determine if the given SMPP data coding scheme is 8-bit or not."""
|
||||||
if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT,
|
if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT,
|
||||||
@@ -140,8 +246,8 @@ class AddressField:
|
|||||||
def to_bytes(self) -> bytes:
|
def to_bytes(self) -> bytes:
|
||||||
"""Encode the AddressField into the binary representation as used in T-PDU."""
|
"""Encode the AddressField into the binary representation as used in T-PDU."""
|
||||||
num_digits = len(self.digits)
|
num_digits = len(self.digits)
|
||||||
if num_digits % 2:
|
# don't store the filler nibble or get_bytes() encodes it as digit and ends up too large
|
||||||
self.digits += 'f'
|
digits = self.digits + 'f' if num_digits % 2 else self.digits
|
||||||
d = {
|
d = {
|
||||||
'addr_len': num_digits,
|
'addr_len': num_digits,
|
||||||
'type_of_addr': {
|
'type_of_addr': {
|
||||||
@@ -149,7 +255,7 @@ class AddressField:
|
|||||||
'type_of_number': self.ton,
|
'type_of_number': self.ton,
|
||||||
'numbering_plan_id': self.npi,
|
'numbering_plan_id': self.npi,
|
||||||
},
|
},
|
||||||
'digits': self.digits,
|
'digits': digits,
|
||||||
}
|
}
|
||||||
return self._construct.build(d)
|
return self._construct.build(d)
|
||||||
|
|
||||||
@@ -169,8 +275,14 @@ class SMS_TPDU(abc.ABC):
|
|||||||
|
|
||||||
class SMS_DELIVER(SMS_TPDU):
|
class SMS_DELIVER(SMS_TPDU):
|
||||||
"""Representation of a SMS-DELIVER T-PDU. This is the Network to MS/UE (downlink) direction."""
|
"""Representation of a SMS-DELIVER T-PDU. This is the Network to MS/UE (downlink) direction."""
|
||||||
flags_construct = BitStruct('tp_rp'/Flag, 'tp_udhi'/Flag, 'tp_rp'/Flag, 'tp_sri'/Flag,
|
flags_construct = BitStruct('tp_rp'/Flag,
|
||||||
Padding(1), 'tp_mms'/Flag, 'tp_mti'/BitsInteger(2))
|
'tp_udhi'/Flag,
|
||||||
|
'tp_sri'/Flag,
|
||||||
|
Padding(1),
|
||||||
|
'tp_lp'/Flag,
|
||||||
|
'tp_mms'/Flag,
|
||||||
|
'tp_mti'/BitsInteger(2))
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
kwargs['tp_mti'] = 0
|
kwargs['tp_mti'] = 0
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
"""Utilities / Functions related to sysmocom sysmoUSIM-SJS1 cards
|
||||||
|
|
||||||
|
(C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
All Rights Reserved
|
||||||
|
|
||||||
|
Author: Eric Wild <ewild@sysmocom.de>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from construct import Struct, Bytes, Flag
|
||||||
|
from osmocom.utils import *
|
||||||
|
from osmocom.construct import *
|
||||||
|
|
||||||
|
from pySim.filesystem import *
|
||||||
|
from pySim.runtime import RuntimeState
|
||||||
|
|
||||||
|
|
||||||
|
class EF_Ki(TransparentEF):
|
||||||
|
_test_de_encode = [
|
||||||
|
('000102030405060708090a0b0c0d0e0f',
|
||||||
|
{'key': h2b('000102030405060708090a0b0c0d0e0f')}),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, fid='00ff', name='EF.Ki'):
|
||||||
|
super().__init__(fid, name=name, desc='K/Ki authentication key', size=(16, 16))
|
||||||
|
self._construct = Struct('key'/Bytes(16))
|
||||||
|
|
||||||
|
|
||||||
|
class EF_OPc(TransparentEF):
|
||||||
|
_test_de_encode = [
|
||||||
|
('016ca53d7a0a804561646816d7b0c702fb',
|
||||||
|
{'use_opc_instead_of_op': True, 'op_opc': h2b('6ca53d7a0a804561646816d7b0c702fb')}),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, fid='00f7', name='EF.OPc'):
|
||||||
|
super().__init__(fid, name=name, desc='OP/OPc for milenage', size=(17, 17))
|
||||||
|
self._construct = Struct('use_opc_instead_of_op'/Flag, 'op_opc'/Bytes(16))
|
||||||
|
|
||||||
|
|
||||||
|
class SysmoUSIMSJS1(CardModel):
|
||||||
|
_atrs = ["3b9f96801fc78031a073be21136743200718000001a5"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_files(cls, rs: RuntimeState):
|
||||||
|
"""Add sysmoUSIM-SJS1 specific files to given RuntimeState."""
|
||||||
|
# the key material lives in DF.GSM shared with ADF.USIM
|
||||||
|
if '7f20' in rs.mf.children:
|
||||||
|
rs.mf.children['7f20'].add_files([EF_Ki(), EF_OPc()])
|
||||||
+101
-40
@@ -3,18 +3,6 @@
|
|||||||
""" pySim: PCSC reader transport link base
|
""" pySim: PCSC reader transport link base
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import abc
|
|
||||||
import argparse
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
from construct import Construct
|
|
||||||
from osmocom.utils import b2h, h2b, i2h, Hexstr
|
|
||||||
|
|
||||||
from pySim.exceptions import *
|
|
||||||
from pySim.utils import SwHexstr, SwMatchstr, ResTuple, sw_match, parse_command_apdu
|
|
||||||
from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright (C) 2009-2010 Sylvain Munaut <tnt@246tNt.com>
|
# Copyright (C) 2009-2010 Sylvain Munaut <tnt@246tNt.com>
|
||||||
# Copyright (C) 2021-2023 Harald Welte <laforge@osmocom.org>
|
# Copyright (C) 2021-2023 Harald Welte <laforge@osmocom.org>
|
||||||
#
|
#
|
||||||
@@ -30,8 +18,20 @@ from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
import abc
|
||||||
|
import argparse
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from construct import Construct
|
||||||
|
from osmocom.utils import b2h, h2b, i2h, Hexstr
|
||||||
|
|
||||||
|
from pySim.exceptions import *
|
||||||
|
from pySim.utils import SwHexstr, SwMatchstr, ResTuple, sw_match, parse_command_apdu
|
||||||
|
from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
|
||||||
|
from pySim.log import PySimLogger
|
||||||
|
|
||||||
|
log = PySimLogger.get(__name__)
|
||||||
|
|
||||||
class ApduTracer:
|
class ApduTracer:
|
||||||
def trace_command(self, cmd):
|
def trace_command(self, cmd):
|
||||||
@@ -45,12 +45,14 @@ class ApduTracer:
|
|||||||
|
|
||||||
class StdoutApduTracer(ApduTracer):
|
class StdoutApduTracer(ApduTracer):
|
||||||
"""Minimalistic APDU tracer, printing commands to stdout."""
|
"""Minimalistic APDU tracer, printing commands to stdout."""
|
||||||
|
def trace_command(self, cmd):
|
||||||
|
log.info("-> %s %s", cmd[:10], cmd[10:])
|
||||||
|
|
||||||
def trace_response(self, cmd, sw, resp):
|
def trace_response(self, cmd, sw, resp):
|
||||||
print("-> %s %s" % (cmd[:10], cmd[10:]))
|
log.info("<- %s: %s", sw, resp)
|
||||||
print("<- %s: %s" % (sw, resp))
|
|
||||||
|
|
||||||
def trace_reset(self):
|
def trace_reset(self):
|
||||||
print("-- RESET")
|
log.info("-- RESET")
|
||||||
|
|
||||||
class ProactiveHandler(abc.ABC):
|
class ProactiveHandler(abc.ABC):
|
||||||
"""Abstract base class representing the interface of some code that handles
|
"""Abstract base class representing the interface of some code that handles
|
||||||
@@ -70,10 +72,26 @@ class ProactiveHandler(abc.ABC):
|
|||||||
raise NotImplementedError('No handler method for %s' % pcmd.decoded)
|
raise NotImplementedError('No handler method for %s' % pcmd.decoded)
|
||||||
|
|
||||||
def prepare_response(self, pcmd: ProactiveCommand, general_result: str = 'performed_successfully'):
|
def prepare_response(self, pcmd: ProactiveCommand, general_result: str = 'performed_successfully'):
|
||||||
|
# TERMINAL RESPONSE per ETSI TS 102 223 section 6.8: Command details (6.8.1) echoed from the
|
||||||
|
# command, Device identities (6.8.2) with source and destination swapped, Result (6.8.3).
|
||||||
|
# pcmd can be
|
||||||
|
# - decoded proactive command IE (.children contains CommandDetails/DeviceIdentities)
|
||||||
|
# - ProactiveCommand collection wrapper (empty .children).
|
||||||
|
# Normalise to the children obj, so both work:
|
||||||
|
# - handler that passes its decoded command
|
||||||
|
# - fallback path that passes collection
|
||||||
|
children = list(getattr(pcmd, 'children', None) or [])
|
||||||
|
if not any(isinstance(c, CommandDetails) for c in children):
|
||||||
|
decoded = getattr(pcmd, 'decoded', None)
|
||||||
|
if decoded is not None and decoded is not pcmd:
|
||||||
|
children = list(getattr(decoded, 'children', None) or [])
|
||||||
# The Command Details are echoed from the command that has been processed.
|
# The Command Details are echoed from the command that has been processed.
|
||||||
(command_details,) = [c for c in pcmd.children if isinstance(c, CommandDetails)]
|
command_details = next((c for c in children if isinstance(c, CommandDetails)), None)
|
||||||
# invert the device identities
|
# invert the device identities
|
||||||
(command_dev_ids,) = [c for c in pcmd.children if isinstance(c, DeviceIdentities)]
|
command_dev_ids = next((c for c in children if isinstance(c, DeviceIdentities)), None)
|
||||||
|
if command_details is None or command_dev_ids is None:
|
||||||
|
raise ValueError('failed to prepare TERMINAL RESPONSE: proactive command has no '
|
||||||
|
'CommandDetails/DeviceIdentities (%r)' % (pcmd,))
|
||||||
rsp_dev_ids = DeviceIdentities()
|
rsp_dev_ids = DeviceIdentities()
|
||||||
rsp_dev_ids.from_dict({'device_identities': {
|
rsp_dev_ids.from_dict({'device_identities': {
|
||||||
'dest_dev_id': command_dev_ids.decoded['source_dev_id'],
|
'dest_dev_id': command_dev_ids.decoded['source_dev_id'],
|
||||||
@@ -90,7 +108,7 @@ class LinkBase(abc.ABC):
|
|||||||
self.sw_interpreter = sw_interpreter
|
self.sw_interpreter = sw_interpreter
|
||||||
self.apdu_tracer = apdu_tracer
|
self.apdu_tracer = apdu_tracer
|
||||||
self.proactive_handler = proactive_handler
|
self.proactive_handler = proactive_handler
|
||||||
self.apdu_strict = False
|
self.apdu_strict = True
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -177,7 +195,7 @@ class LinkBase(abc.ABC):
|
|||||||
if self.apdu_strict:
|
if self.apdu_strict:
|
||||||
raise ValueError(exeption_str)
|
raise ValueError(exeption_str)
|
||||||
else:
|
else:
|
||||||
print('Warning: %s' % exeption_str)
|
log.warning(exeption_str)
|
||||||
|
|
||||||
return (data, sw)
|
return (data, sw)
|
||||||
|
|
||||||
@@ -211,7 +229,7 @@ class LinkBase(abc.ABC):
|
|||||||
# parse the proactive command
|
# parse the proactive command
|
||||||
pcmd = ProactiveCommand()
|
pcmd = ProactiveCommand()
|
||||||
parsed = pcmd.from_tlv(h2b(fetch_rv[0]))
|
parsed = pcmd.from_tlv(h2b(fetch_rv[0]))
|
||||||
print("FETCH: %s (%s)" % (fetch_rv[0], type(parsed).__name__))
|
log.info("FETCH: %s (%s)", fetch_rv[0], type(parsed).__name__)
|
||||||
if self.proactive_handler:
|
if self.proactive_handler:
|
||||||
# Extension point: If this does return a list of TLV objects,
|
# Extension point: If this does return a list of TLV objects,
|
||||||
# they could be appended after the Result; if the first is a
|
# they could be appended after the Result; if the first is a
|
||||||
@@ -301,24 +319,67 @@ class LinkBaseTpdu(LinkBase):
|
|||||||
|
|
||||||
prev_tpdu = tpdu
|
prev_tpdu = tpdu
|
||||||
data, sw = self.send_tpdu(tpdu)
|
data, sw = self.send_tpdu(tpdu)
|
||||||
|
log.debug("T0: case #%u TPDU: %s => %s %s", case, tpdu, data or "(no data)", sw or "(no status word)")
|
||||||
|
if sw is None:
|
||||||
|
raise ValueError("no status word received")
|
||||||
|
|
||||||
# When we have sent the first APDU, the SW may indicate that there are response bytes
|
# After sending the APDU/TPDU the UICC/eUICC or SIM may response with a status word that indicates that further
|
||||||
# available. There are two SWs commonly used for this 9fxx (sim) and 61xx (usim), where
|
# TPDUs have to be sent in order to complete the task.
|
||||||
# xx is the number of response bytes available.
|
if case == 4 or self.apdu_strict == False:
|
||||||
# See also:
|
# In case the APDU is a case #4 APDU, the UICC/eUICC/SIM may indicate that there is response data
|
||||||
if sw is not None:
|
# available which has to be retrieved using a GET RESPONSE command TPDU.
|
||||||
while (sw[0:2] in ['9f', '61', '62', '63']):
|
#
|
||||||
# SW1=9F: 3GPP TS 51.011 9.4.1, Responses to commands which are correctly executed
|
# ETSI TS 102 221, section 7.3.1.1.4 is very cleare about the fact that the GET RESPONSE mechanism
|
||||||
# SW1=61: ISO/IEC 7816-4, Table 5 — General meaning of the interindustry values of SW1-SW2
|
# shall only apply on case #4 APDUs but unfortunately it is impossible to distinguish between case #3
|
||||||
# SW1=62: ETSI TS 102 221 7.3.1.1.4 Clause 4b): 62xx, 63xx, 9xxx != 9000
|
# and case #4 when the APDU format is not strictly followed. In order to be able to detect case #4
|
||||||
tpdu_gr = tpdu[0:2] + 'c00000' + sw[2:4]
|
# correctly the Le byte (usually 0x00) must be present, is often forgotten. To avoid problems with
|
||||||
|
# legacy scripts that use raw APDU strings, we will still loosely apply GET RESPONSE based on what
|
||||||
|
# the status word indicates. Unless the user explicitly enables the strict mode (set apdu_strict true)
|
||||||
|
#
|
||||||
|
# The dummy GET RESPONSE of clause 4b (see below) is one shot: it turns a warning SW into the 61xx
|
||||||
|
# that announces the response length. It is only ever a valid reaction to the SW returned for the
|
||||||
|
# _command_ TPDU. Once a response has been fetched there is nothing left to announce, so a warning
|
||||||
|
# SW is the final result of the command and has to be passed on to the caller unmodified.
|
||||||
|
#
|
||||||
|
# This matters because the 62xx/63xx range is not exclusive to ETSI TS 102 221.
|
||||||
|
# GPC v2.3.1 section 11.4.3.2 table 11-38 GP GET STATUS (80 F2) answers
|
||||||
|
# 6310 "more data available", meaning "reissue with P2 bit 1 set" as per section 11.4.2.2 table 11-34
|
||||||
|
# rather than "response data is waiting". Trying a random GET RESPONSE at that point
|
||||||
|
# makes the card answer 6982 and tears down the whole SCP session and following commands fail with 6985.
|
||||||
|
dummy_gr_allowed = not data
|
||||||
|
while True:
|
||||||
|
if sw in ['9000', '9100']:
|
||||||
|
# A status word of 9000 (or 9100 in case there is pending data from a proactive SIM command)
|
||||||
|
# indicates that either no response data was returnd or all response data has been retrieved
|
||||||
|
# successfully. We may discontinue the processing at this point.
|
||||||
|
break;
|
||||||
|
if sw[0:2] in ['61', '9f']:
|
||||||
|
# A status word of 61xx or 9fxx indicates that there is (still) response data available. We
|
||||||
|
# send a GET RESPONSE command with the length value indicated in the second byte of the status
|
||||||
|
# word. (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4a and 3GPP TS 51.011 9.4.1 and
|
||||||
|
# ISO/IEC 7816-4, Table 5)
|
||||||
|
le_gr = sw[2:4]
|
||||||
|
elif sw[0:2] in ['62', '63'] and dummy_gr_allowed:
|
||||||
|
# There are corner cases (status word is 62xx or 63xx) where the UICC/eUICC/SIM asks us
|
||||||
|
# to send a dummy GET RESPONSE command. We send a GET RESPONSE command with a length of 0.
|
||||||
|
# (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4b and ETSI TS 151 011, section 9.4.1)
|
||||||
|
le_gr = '00'
|
||||||
|
else:
|
||||||
|
# A status word other then the ones covered by the above logic may indicate an error. In this
|
||||||
|
# case we will discontinue the processing as well.
|
||||||
|
# (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4c)
|
||||||
|
break
|
||||||
|
tpdu_gr = tpdu[0:2] + 'c00000' + le_gr
|
||||||
prev_tpdu = tpdu_gr
|
prev_tpdu = tpdu_gr
|
||||||
d, sw = self.send_tpdu(tpdu_gr)
|
data_gr, sw = self.send_tpdu(tpdu_gr)
|
||||||
data += d
|
log.debug("T0: GET RESPONSE TPDU: %s => %s %s", tpdu_gr, data_gr or "(no data)", sw or "(no status word)")
|
||||||
if sw[0:2] == '6c':
|
data += data_gr
|
||||||
# SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding
|
dummy_gr_allowed = False
|
||||||
tpdu_gr = prev_tpdu[0:8] + sw[2:4]
|
if sw[0:2] == '6c':
|
||||||
data, sw = self.send_tpdu(tpdu_gr)
|
# SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding
|
||||||
|
tpdu_gr = prev_tpdu[0:8] + sw[2:4]
|
||||||
|
data, sw = self.send_tpdu(tpdu_gr)
|
||||||
|
log.debug("T0: repated case #%u TPDU: %s => %s %s", case, tpdu_gr, data or "(no data)", sw or "(no status word)")
|
||||||
|
|
||||||
return data, sw
|
return data, sw
|
||||||
|
|
||||||
@@ -361,13 +422,13 @@ def init_reader(opts, **kwargs) -> LinkBase:
|
|||||||
from pySim.transport.modem_atcmd import ModemATCommandLink
|
from pySim.transport.modem_atcmd import ModemATCommandLink
|
||||||
sl = ModemATCommandLink(opts, **kwargs)
|
sl = ModemATCommandLink(opts, **kwargs)
|
||||||
else: # Serial reader is default
|
else: # Serial reader is default
|
||||||
print("No reader/driver specified; falling back to default (Serial reader)")
|
log.warning("No reader/driver specified; falling back to default (Serial reader)")
|
||||||
from pySim.transport.serial import SerialSimLink
|
from pySim.transport.serial import SerialSimLink
|
||||||
sl = SerialSimLink(opts, **kwargs)
|
sl = SerialSimLink(opts, **kwargs)
|
||||||
|
|
||||||
if os.environ.get('PYSIM_INTEGRATION_TEST') == "1":
|
if os.environ.get('PYSIM_INTEGRATION_TEST') == "1":
|
||||||
print("Using %s reader interface" % (sl.name))
|
log.info("Using %s reader interface" % (sl.name))
|
||||||
else:
|
else:
|
||||||
print("Using reader %s" % sl)
|
log.info("Using reader %s" % sl)
|
||||||
|
|
||||||
return sl
|
return sl
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class ModemATCommandLink(LinkBaseTpdu):
|
|||||||
|
|
||||||
# Make sure that the response has format: b'+CSIM: %d,\"%s\"'
|
# Make sure that the response has format: b'+CSIM: %d,\"%s\"'
|
||||||
try:
|
try:
|
||||||
result = re.match(b'\+CSIM: (\d+),\"([0-9A-F]+)\"', rsp)
|
result = re.match(rb'\+CSIM: (\d+),\"([0-9A-F]+)\"', rsp)
|
||||||
(_rsp_tpdu_len, rsp_tpdu) = result.groups()
|
(_rsp_tpdu_len, rsp_tpdu) = result.groups()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ReaderError('Failed to parse response from modem: %s' % rsp) from exc
|
raise ReaderError('Failed to parse response from modem: %s' % rsp) from exc
|
||||||
|
|||||||
+12
-9
@@ -26,6 +26,7 @@ from smartcard.CardRequest import CardRequest
|
|||||||
from smartcard.Exceptions import NoCardException, CardRequestTimeoutException, CardConnectionException
|
from smartcard.Exceptions import NoCardException, CardRequestTimeoutException, CardConnectionException
|
||||||
from smartcard.System import readers
|
from smartcard.System import readers
|
||||||
from smartcard.ExclusiveConnectCardConnection import ExclusiveConnectCardConnection
|
from smartcard.ExclusiveConnectCardConnection import ExclusiveConnectCardConnection
|
||||||
|
from smartcard.ATR import ATR
|
||||||
|
|
||||||
from osmocom.utils import h2i, i2h, Hexstr
|
from osmocom.utils import h2i, i2h, Hexstr
|
||||||
|
|
||||||
@@ -80,23 +81,25 @@ class PcscSimLink(LinkBaseTpdu):
|
|||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
try:
|
try:
|
||||||
# To avoid leakage of resources, make sure the reader
|
# To avoid leakage of resources, make sure the reader is disconnected
|
||||||
# is disconnected
|
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
|
|
||||||
# Make card connection and select a suitable communication protocol
|
# Make card connection and select a suitable communication protocol
|
||||||
|
# (Even though pyscard provides an automatic protocol selection, we will make an independent decision
|
||||||
|
# based on the ATR. There are two reasons for that:
|
||||||
|
# 1) In case a card supports T=0 and T=1, we perfer to use T=0.
|
||||||
|
# 2) The automatic protocol selection may be unreliabe on some platforms
|
||||||
|
# see also: https://osmocom.org/issues/6952)
|
||||||
self._con.connect()
|
self._con.connect()
|
||||||
supported_protocols = self._con.getProtocol();
|
atr = ATR(self._con.getATR())
|
||||||
self.disconnect()
|
if atr.isT0Supported():
|
||||||
if (supported_protocols & CardConnection.T0_protocol):
|
self._con.setProtocol(CardConnection.T0_protocol)
|
||||||
protocol = CardConnection.T0_protocol
|
|
||||||
self.set_tpdu_format(0)
|
self.set_tpdu_format(0)
|
||||||
elif (supported_protocols & CardConnection.T1_protocol):
|
elif atr.isT1Supported():
|
||||||
protocol = CardConnection.T1_protocol
|
self._con.setProtocol(CardConnection.T1_protocol)
|
||||||
self.set_tpdu_format(1)
|
self.set_tpdu_format(1)
|
||||||
else:
|
else:
|
||||||
raise ReaderError('Unsupported card protocol')
|
raise ReaderError('Unsupported card protocol')
|
||||||
self._con.connect(protocol)
|
|
||||||
except CardConnectionException as exc:
|
except CardConnectionException as exc:
|
||||||
raise ProtocolError() from exc
|
raise ProtocolError() from exc
|
||||||
except NoCardException as exc:
|
except NoCardException as exc:
|
||||||
|
|||||||
+36
-11
@@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
"""
|
"""
|
||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
|
import copy
|
||||||
|
|
||||||
from construct import Select, Const, Bit, Struct, Int16ub, FlagsEnum, GreedyString, ValidationError
|
from construct import Select, Const, Bit, Struct, Int16ub, FlagsEnum, GreedyString, ValidationError
|
||||||
from construct import Optional as COptional, Computed
|
from construct import Optional as COptional, Computed
|
||||||
@@ -335,6 +336,8 @@ class TerminalCapability(BER_TLV_IE, tag=0xa9, nested=[TerminalPowerSupply, Exte
|
|||||||
|
|
||||||
# ETSI TS 102 221 Section 9.2.7 + ISO7816-4 9.3.3/9.3.4
|
# ETSI TS 102 221 Section 9.2.7 + ISO7816-4 9.3.3/9.3.4
|
||||||
class _AM_DO_DF(DataObject):
|
class _AM_DO_DF(DataObject):
|
||||||
|
"""ISO7816-4:2005 5.4.3.1 Table 16"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('access_mode', 'Access Mode', tag=0x80)
|
super().__init__('access_mode', 'Access Mode', tag=0x80)
|
||||||
|
|
||||||
@@ -381,7 +384,7 @@ class _AM_DO_DF(DataObject):
|
|||||||
|
|
||||||
|
|
||||||
class _AM_DO_EF(DataObject):
|
class _AM_DO_EF(DataObject):
|
||||||
"""ISO7816-4 9.3.2 Table 18 + 9.3.3.1 Table 31"""
|
"""ISO7816-4:2005 5.4.3.1 Table 17"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('access_mode', 'Access Mode', tag=0x80)
|
super().__init__('access_mode', 'Access Mode', tag=0x80)
|
||||||
@@ -429,7 +432,7 @@ class _AM_DO_EF(DataObject):
|
|||||||
|
|
||||||
|
|
||||||
class _AM_DO_CHDR(DataObject):
|
class _AM_DO_CHDR(DataObject):
|
||||||
"""Command Header Access Mode DO according to ISO 7816-4 Table 32."""
|
"""Command Header Access Mode DO according to ISO 7816-4:2005 5.4.3.2 Table 22."""
|
||||||
|
|
||||||
def __init__(self, tag):
|
def __init__(self, tag):
|
||||||
super().__init__('command_header', 'Command Header Description', tag=tag)
|
super().__init__('command_header', 'Command Header Description', tag=tag)
|
||||||
@@ -543,8 +546,9 @@ class CRT_DO(DataObject):
|
|||||||
pin = pin_names.inverse[self.decoded]
|
pin = pin_names.inverse[self.decoded]
|
||||||
return b'\x83\x01' + pin.to_bytes(1, 'big') + b'\x95\x01\x08'
|
return b'\x83\x01' + pin.to_bytes(1, 'big') + b'\x95\x01\x08'
|
||||||
|
|
||||||
# ISO7816-4 9.3.3 Table 33
|
|
||||||
class SecCondByte_DO(DataObject):
|
class SecCondByte_DO(DataObject):
|
||||||
|
"""ISO7816-4:2005 5.4.3.1 Table 20"""
|
||||||
|
|
||||||
def __init__(self, tag=0x9d):
|
def __init__(self, tag=0x9d):
|
||||||
super().__init__('security_condition_byte', tag=tag)
|
super().__init__('security_condition_byte', tag=tag)
|
||||||
|
|
||||||
@@ -732,36 +736,57 @@ class EF_ARR(LinFixedEF):
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
return by_mode
|
return by_mode
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_do_sequence(decode_for_df : bool = False):
|
||||||
|
if decode_for_df:
|
||||||
|
return DataObjectSequence('arr', sequence=[AM_DO_DF, SC_DO])
|
||||||
|
else:
|
||||||
|
return DataObjectSequence('arr', sequence=[AM_DO_EF, SC_DO])
|
||||||
|
|
||||||
def _decode_record_bin(self, raw_bin_data, **kwargs):
|
def _decode_record_bin(self, raw_bin_data, **kwargs):
|
||||||
# we can only guess if we should decode for EF or DF here :(
|
# we can only guess if we should decode for EF or DF here, but our caller may
|
||||||
arr_seq = DataObjectSequence('arr', sequence=[AM_DO_EF, SC_DO])
|
# be able to pass us a hint:
|
||||||
|
arr_seq = self.__get_do_sequence(kwargs.get('decode_for_df', False))
|
||||||
dec = arr_seq.decode_multi(raw_bin_data)
|
dec = arr_seq.decode_multi(raw_bin_data)
|
||||||
# we cannot pass the result through flatten() here, as we don't have a related
|
# we cannot pass the result through flatten() here, as we don't have a related
|
||||||
# 'un-flattening' decoder, and hence would be unable to encode :(
|
# 'un-flattening' decoder, and hence would be unable to encode :(
|
||||||
return dec[0]
|
return dec[0]
|
||||||
|
|
||||||
def _encode_record_bin(self, in_json, **kwargs):
|
def _encode_record_bin(self, in_json, **kwargs):
|
||||||
# we can only guess if we should decode for EF or DF here :(
|
# we can only guess if we should decode for EF or DF here, but our caller may
|
||||||
arr_seq = DataObjectSequence('arr', sequence=[AM_DO_EF, SC_DO])
|
# be able to pass us a hint:
|
||||||
|
arr_seq = self.__get_do_sequence(kwargs.get('encode_for_df', False))
|
||||||
return arr_seq.encode_multi(in_json)
|
return arr_seq.encode_multi(in_json)
|
||||||
|
|
||||||
@with_default_category('File-Specific Commands')
|
@with_default_category('File-Specific Commands')
|
||||||
class AddlShellCommands(CommandSet):
|
class AddlShellCommands(CommandSet):
|
||||||
@cmd2.with_argparser(LinFixedEF.ShellCommands.read_rec_dec_parser)
|
read_arr_argparser = copy.deepcopy(LinFixedEF.ShellCommands.read_rec_dec_parser)
|
||||||
|
read_arr_argparser.add_argument('--decode-for-df', action='store_true',
|
||||||
|
help='Decode EF.ARR record as if used by a DF (default: EF)')
|
||||||
|
|
||||||
|
@cmd2.with_argparser(read_arr_argparser)
|
||||||
def do_read_arr_record(self, opts):
|
def do_read_arr_record(self, opts):
|
||||||
"""Read one EF.ARR record in flattened, human-friendly form."""
|
"""Read one EF.ARR record in flattened, human-friendly form."""
|
||||||
(data, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR)
|
(hexdata, _sw) = self._cmd.lchan.read_record(opts.RECORD_NR)
|
||||||
|
data = self._cmd.lchan.selected_file._decode_record_bin(h2b(hexdata),
|
||||||
|
decode_for_df = opts.decode_for_df)
|
||||||
data = self._cmd.lchan.selected_file.flatten(data)
|
data = self._cmd.lchan.selected_file.flatten(data)
|
||||||
self._cmd.poutput_json(data, opts.oneline)
|
self._cmd.poutput_json(data, opts.oneline)
|
||||||
|
|
||||||
@cmd2.with_argparser(LinFixedEF.ShellCommands.read_recs_dec_parser)
|
read_arrs_argparser = copy.deepcopy(LinFixedEF.ShellCommands.read_recs_dec_parser)
|
||||||
|
read_arrs_argparser.add_argument('--decode-for-df', action='store_true',
|
||||||
|
help='Decode EF.ARR records as if used by a DF (default: EF)')
|
||||||
|
|
||||||
|
@cmd2.with_argparser(read_arrs_argparser)
|
||||||
def do_read_arr_records(self, opts):
|
def do_read_arr_records(self, opts):
|
||||||
"""Read + decode all EF.ARR records in flattened, human-friendly form."""
|
"""Read + decode all EF.ARR records in flattened, human-friendly form."""
|
||||||
num_of_rec = self._cmd.lchan.selected_file_num_of_rec()
|
num_of_rec = self._cmd.lchan.selected_file_num_of_rec()
|
||||||
# collect all results in list so they are rendered as JSON list when printing
|
# collect all results in list so they are rendered as JSON list when printing
|
||||||
data_list = []
|
data_list = []
|
||||||
for recnr in range(1, 1 + num_of_rec):
|
for recnr in range(1, 1 + num_of_rec):
|
||||||
(data, _sw) = self._cmd.lchan.read_record_dec(recnr)
|
(hexdata, _sw) = self._cmd.lchan.read_record(recnr)
|
||||||
|
data = self._cmd.lchan.selected_file._decode_record_bin(h2b(hexdata),
|
||||||
|
decode_for_df = opts.decode_for_df)
|
||||||
data = self._cmd.lchan.selected_file.flatten(data)
|
data = self._cmd.lchan.selected_file.flatten(data)
|
||||||
data_list.append(data)
|
data_list.append(data)
|
||||||
self._cmd.poutput_json(data_list, opts.oneline)
|
self._cmd.poutput_json(data_list, opts.oneline)
|
||||||
|
|||||||
+19
-11
@@ -285,6 +285,14 @@ class EF_SUCI_Calc_Info(TransparentEF):
|
|||||||
{"hnet_pubkey_identifier": 11, "hnet_pubkey":
|
{"hnet_pubkey_identifier": 11, "hnet_pubkey":
|
||||||
h2b("d1bc365f4997d17ce4374e72181431cbfeba9e1b98d7618f79d48561b144672a")}]} ),
|
h2b("d1bc365f4997d17ce4374e72181431cbfeba9e1b98d7618f79d48561b144672a")}]} ),
|
||||||
]
|
]
|
||||||
|
_test_decode = [
|
||||||
|
( 'A000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
|
||||||
|
{"prot_scheme_id_list": [],
|
||||||
|
"hnet_pubkey_list": []} ),
|
||||||
|
( 'A000A100FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
|
||||||
|
{"prot_scheme_id_list": [],
|
||||||
|
"hnet_pubkey_list": []} ),
|
||||||
|
]
|
||||||
# 3GPP TS 31.102 Section 4.4.11.8
|
# 3GPP TS 31.102 Section 4.4.11.8
|
||||||
class ProtSchemeIdList(BER_TLV_IE, tag=0xa0):
|
class ProtSchemeIdList(BER_TLV_IE, tag=0xa0):
|
||||||
# FIXME: 3GPP TS 24.501 Protection Scheme Identifier
|
# FIXME: 3GPP TS 24.501 Protection Scheme Identifier
|
||||||
@@ -327,7 +335,7 @@ class EF_SUCI_Calc_Info(TransparentEF):
|
|||||||
"""conversion method to generate list of {hnet_pubkey_identifier, hnet_pubkey} dicts
|
"""conversion method to generate list of {hnet_pubkey_identifier, hnet_pubkey} dicts
|
||||||
from flat [{hnet_pubkey_identifier: }, {net_pubkey: }, ...] list"""
|
from flat [{hnet_pubkey_identifier: }, {net_pubkey: }, ...] list"""
|
||||||
out = []
|
out = []
|
||||||
while len(l):
|
while l:
|
||||||
a = l.pop(0)
|
a = l.pop(0)
|
||||||
b = l.pop(0)
|
b = l.pop(0)
|
||||||
z = {**a, **b}
|
z = {**a, **b}
|
||||||
@@ -389,7 +397,7 @@ class EF_SUCI_Calc_Info(TransparentEF):
|
|||||||
# remaining data holds Home Network Public Key Data Object
|
# remaining data holds Home Network Public Key Data Object
|
||||||
hpkl = EF_SUCI_Calc_Info.HnetPubkeyList()
|
hpkl = EF_SUCI_Calc_Info.HnetPubkeyList()
|
||||||
hpkl.from_tlv(in_bytes[pos:])
|
hpkl.from_tlv(in_bytes[pos:])
|
||||||
hnet_pubkey_list = self._compact_pubkey_list(hpkl.to_dict()['hnet_pubkey_list'])
|
hnet_pubkey_list = self._compact_pubkey_list(hpkl.to_dict()['hnet_pubkey_list'] or [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'prot_scheme_id_list': prot_scheme_id_list,
|
'prot_scheme_id_list': prot_scheme_id_list,
|
||||||
@@ -486,17 +494,17 @@ class EF_UST(EF_UServiceTable):
|
|||||||
# TS 31.103 Section 4.2.7 - *not* the same as DF.GSM/EF.ECC!
|
# TS 31.103 Section 4.2.7 - *not* the same as DF.GSM/EF.ECC!
|
||||||
class EF_ECC(LinFixedEF):
|
class EF_ECC(LinFixedEF):
|
||||||
_test_de_encode = [
|
_test_de_encode = [
|
||||||
( '19f1ff01', { "call_code": "911f",
|
( '19f1ff01', { "call_code": "911",
|
||||||
"service_category": { "police": True, "ambulance": False, "fire_brigade": False,
|
"service_category": { "police": True, "ambulance": False, "fire_brigade": False,
|
||||||
"marine_guard": False, "mountain_rescue": False,
|
"marine_guard": False, "mountain_rescue": False,
|
||||||
"manual_ecall": False, "automatic_ecall": False } } ),
|
"manual_ecall": False, "automatic_ecall": False } } ),
|
||||||
( '19f3ff02', { "call_code": "913f",
|
( '19f3ff02', { "call_code": "913",
|
||||||
"service_category": { "police": False, "ambulance": True, "fire_brigade": False,
|
"service_category": { "police": False, "ambulance": True, "fire_brigade": False,
|
||||||
"marine_guard": False, "mountain_rescue": False,
|
"marine_guard": False, "mountain_rescue": False,
|
||||||
"manual_ecall": False, "automatic_ecall": False } } ),
|
"manual_ecall": False, "automatic_ecall": False } } ),
|
||||||
]
|
]
|
||||||
_test_no_pad = True
|
_test_no_pad = True
|
||||||
cc_construct = BcdAdapter(Rpad(Bytes(3)))
|
cc_construct = PaddedBcdAdapter(Rpad(Bytes(3)))
|
||||||
category_construct = FlagsEnum(Byte, police=1, ambulance=2, fire_brigade=3, marine_guard=4,
|
category_construct = FlagsEnum(Byte, police=1, ambulance=2, fire_brigade=3, marine_guard=4,
|
||||||
mountain_rescue=5, manual_ecall=6, automatic_ecall=7)
|
mountain_rescue=5, manual_ecall=6, automatic_ecall=7)
|
||||||
alpha_construct = GsmOrUcs2Adapter(Rpad(GreedyBytes))
|
alpha_construct = GsmOrUcs2Adapter(Rpad(GreedyBytes))
|
||||||
@@ -596,7 +604,7 @@ class EF_ICI(CyclicEF):
|
|||||||
self._construct = Struct('alpha_id'/Bytes(this._.total_len-28),
|
self._construct = Struct('alpha_id'/Bytes(this._.total_len-28),
|
||||||
'len_of_bcd_contents'/Int8ub,
|
'len_of_bcd_contents'/Int8ub,
|
||||||
'ton_npi'/Int8ub,
|
'ton_npi'/Int8ub,
|
||||||
'call_number'/BcdAdapter(Bytes(10)),
|
'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))),
|
||||||
'cap_cfg2_record_id'/Int8ub,
|
'cap_cfg2_record_id'/Int8ub,
|
||||||
'ext5_record_id'/Int8ub,
|
'ext5_record_id'/Int8ub,
|
||||||
'date_and_time'/BcdAdapter(Bytes(7)),
|
'date_and_time'/BcdAdapter(Bytes(7)),
|
||||||
@@ -612,7 +620,7 @@ class EF_OCI(CyclicEF):
|
|||||||
self._construct = Struct('alpha_id'/Bytes(this._.total_len-27),
|
self._construct = Struct('alpha_id'/Bytes(this._.total_len-27),
|
||||||
'len_of_bcd_contents'/Int8ub,
|
'len_of_bcd_contents'/Int8ub,
|
||||||
'ton_npi'/Int8ub,
|
'ton_npi'/Int8ub,
|
||||||
'call_number'/BcdAdapter(Bytes(10)),
|
'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))),
|
||||||
'cap_cfg2_record_id'/Int8ub,
|
'cap_cfg2_record_id'/Int8ub,
|
||||||
'ext5_record_id'/Int8ub,
|
'ext5_record_id'/Int8ub,
|
||||||
'date_and_time'/BcdAdapter(Bytes(7)),
|
'date_and_time'/BcdAdapter(Bytes(7)),
|
||||||
@@ -1058,7 +1066,7 @@ class EF_OCSGL(LinFixedEF):
|
|||||||
# TS 31.102 Section 4.4.11.2 (Rel 15)
|
# TS 31.102 Section 4.4.11.2 (Rel 15)
|
||||||
class EF_5GS3GPPLOCI(TransparentEF):
|
class EF_5GS3GPPLOCI(TransparentEF):
|
||||||
def __init__(self, fid='4f01', sfid=0x01, name='EF.5GS3GPPLOCI', size=(20, 20),
|
def __init__(self, fid='4f01', sfid=0x01, name='EF.5GS3GPPLOCI', size=(20, 20),
|
||||||
desc='5S 3GP location information', **kwargs):
|
desc='5GS 3GPP location information', **kwargs):
|
||||||
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
|
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
|
||||||
upd_status_constr = Enum(
|
upd_status_constr = Enum(
|
||||||
Byte, updated=0, not_updated=1, roaming_not_allowed=2)
|
Byte, updated=0, not_updated=1, roaming_not_allowed=2)
|
||||||
@@ -1118,7 +1126,7 @@ class EF_Routing_Indicator(TransparentEF):
|
|||||||
# responsibility of home network operator but BCD coding shall be used. If a network
|
# responsibility of home network operator but BCD coding shall be used. If a network
|
||||||
# operator decides to assign less than 4 digits to Routing Indicator, the remaining digits
|
# operator decides to assign less than 4 digits to Routing Indicator, the remaining digits
|
||||||
# shall be coded as "1111" to fill the 4 digits coding of Routing Indicator
|
# shall be coded as "1111" to fill the 4 digits coding of Routing Indicator
|
||||||
self._construct = Struct('routing_indicator'/Rpad(BcdAdapter(Bytes(2)), 'f', 2),
|
self._construct = Struct('routing_indicator'/PaddedBcdAdapter(Rpad(Bytes(2))),
|
||||||
'rfu'/Bytes(2))
|
'rfu'/Bytes(2))
|
||||||
|
|
||||||
# TS 31.102 Section 4.4.11.13 (Rel 16)
|
# TS 31.102 Section 4.4.11.13 (Rel 16)
|
||||||
@@ -1326,7 +1334,7 @@ class EF_5G_PROSE_UIR(TransparentEF):
|
|||||||
pass
|
pass
|
||||||
class FiveGDdnmfCtfAddrForUploading(BER_TLV_IE, tag=0x97):
|
class FiveGDdnmfCtfAddrForUploading(BER_TLV_IE, tag=0x97):
|
||||||
pass
|
pass
|
||||||
class ProSeConfigDataForUeToNetworkRelayUE(BER_TLV_IE, tag=0xa0,
|
class ProSeConfigDataForUsageInfoReporting(BER_TLV_IE, tag=0xa0,
|
||||||
nested=[EF_5G_PROSE_DD.ValidityTimer,
|
nested=[EF_5G_PROSE_DD.ValidityTimer,
|
||||||
CollectionPeriod, ReportingWindow,
|
CollectionPeriod, ReportingWindow,
|
||||||
ReportingIndicators,
|
ReportingIndicators,
|
||||||
@@ -1336,7 +1344,7 @@ class EF_5G_PROSE_UIR(TransparentEF):
|
|||||||
desc='5G ProSe configuration data for usage information reporting', **kwargs):
|
desc='5G ProSe configuration data for usage information reporting', **kwargs):
|
||||||
super().__init__(fid, sfid=sfid, name=name, desc=desc, **kwargs)
|
super().__init__(fid, sfid=sfid, name=name, desc=desc, **kwargs)
|
||||||
# contains TLV structure despite being TransparentEF, not BER-TLV ?!?
|
# contains TLV structure despite being TransparentEF, not BER-TLV ?!?
|
||||||
self._tlv = EF_5G_PROSE_UIR.ProSeConfigDataForUeToNetworkRelayUE
|
self._tlv = EF_5G_PROSE_UIR.ProSeConfigDataForUsageInfoReporting
|
||||||
|
|
||||||
# TS 31.102 Section 4.4.13.8 (Rel 18)
|
# TS 31.102 Section 4.4.13.8 (Rel 18)
|
||||||
class EF_5G_PROSE_U2URU(TransparentEF):
|
class EF_5G_PROSE_U2URU(TransparentEF):
|
||||||
|
|||||||
+132
-33
@@ -40,6 +40,7 @@ from osmocom.utils import *
|
|||||||
from osmocom.construct import *
|
from osmocom.construct import *
|
||||||
|
|
||||||
from pySim.utils import dec_iccid, enc_iccid, dec_imsi, enc_imsi, dec_plmn, enc_plmn, dec_xplmn_w_act
|
from pySim.utils import dec_iccid, enc_iccid, dec_imsi, enc_imsi, dec_plmn, enc_plmn, dec_xplmn_w_act
|
||||||
|
from pySim.utils import bytes_for_nibbles
|
||||||
from pySim.profile import CardProfile, CardProfileAddon
|
from pySim.profile import CardProfile, CardProfileAddon
|
||||||
from pySim.filesystem import *
|
from pySim.filesystem import *
|
||||||
from pySim.ts_31_102_telecom import DF_PHONEBOOK, DF_MULTIMEDIA, DF_MCS, DF_V2X
|
from pySim.ts_31_102_telecom import DF_PHONEBOOK, DF_MULTIMEDIA, DF_MCS, DF_V2X
|
||||||
@@ -151,7 +152,7 @@ class EF_ADN(LinFixedEF):
|
|||||||
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-14)))),
|
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-14)))),
|
||||||
'len_of_bcd'/Int8ub,
|
'len_of_bcd'/Int8ub,
|
||||||
'ton_npi'/TonNpi,
|
'ton_npi'/TonNpi,
|
||||||
'dialing_nr'/ExtendedBcdAdapter(BcdAdapter(Rpad(Bytes(10)))),
|
'dialing_nr'/ExtendedBcdAdapter(PaddedBcdAdapter(Rpad(Bytes(10)))),
|
||||||
'cap_conf_id'/Int8ub,
|
'cap_conf_id'/Int8ub,
|
||||||
ext_name/Int8ub)
|
ext_name/Int8ub)
|
||||||
|
|
||||||
@@ -192,11 +193,11 @@ class EF_MSISDN(LinFixedEF):
|
|||||||
( 'ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff',
|
( 'ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff',
|
||||||
{"alpha_id": "", "len_of_bcd": 4, "ton_npi": {"ext": True, "type_of_number": "network_specific",
|
{"alpha_id": "", "len_of_bcd": 4, "ton_npi": {"ext": True, "type_of_number": "network_specific",
|
||||||
"numbering_plan_id": "isdn_e164"},
|
"numbering_plan_id": "isdn_e164"},
|
||||||
"dialing_nr": "12345f"}),
|
"dialing_nr": "12345"}),
|
||||||
( '456967656e65205275666e756d6d6572ffffffff0891947172199181f3ffffffffff',
|
( '456967656e65205275666e756d6d6572ffffffff0891947172199181f3ffffffffff',
|
||||||
{"alpha_id": "Eigene Rufnummer", "len_of_bcd": 8, "ton_npi": {"ext": True, "type_of_number": "international",
|
{"alpha_id": "Eigene Rufnummer", "len_of_bcd": 8, "ton_npi": {"ext": True, "type_of_number": "international",
|
||||||
"numbering_plan_id": "isdn_e164"},
|
"numbering_plan_id": "isdn_e164"},
|
||||||
"dialing_nr": "4917279119183f"}),
|
"dialing_nr": "4917279119183"}),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ensure deprecated representations still work
|
# Ensure deprecated representations still work
|
||||||
@@ -214,7 +215,7 @@ class EF_MSISDN(LinFixedEF):
|
|||||||
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-14)))),
|
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-14)))),
|
||||||
'len_of_bcd'/Int8ub,
|
'len_of_bcd'/Int8ub,
|
||||||
'ton_npi'/TonNpi,
|
'ton_npi'/TonNpi,
|
||||||
'dialing_nr'/ExtendedBcdAdapter(BcdAdapter(Rpad(Bytes(10)))),
|
'dialing_nr'/ExtendedBcdAdapter(PaddedBcdAdapter(Rpad(Bytes(10)))),
|
||||||
Padding(2, pattern=b'\xff'))
|
Padding(2, pattern=b'\xff'))
|
||||||
|
|
||||||
# Maintain compatibility with deprecated representations
|
# Maintain compatibility with deprecated representations
|
||||||
@@ -239,11 +240,30 @@ class EF_MSISDN(LinFixedEF):
|
|||||||
|
|
||||||
# TS 51.011 Section 10.5.6
|
# TS 51.011 Section 10.5.6
|
||||||
class EF_SMSP(LinFixedEF):
|
class EF_SMSP(LinFixedEF):
|
||||||
# FIXME: re-encode fails / missing alpha_id at start of output
|
_test_de_encode = [
|
||||||
_test_decode = [
|
( '534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9',
|
||||||
|
{ "alpha_id": "SMSC", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
|
||||||
|
"tp_pid": True, "tp_dcs": True, "tp_vp": True },
|
||||||
|
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
|
||||||
|
"numbering_plan_id": "reserved_for_extension" },
|
||||||
|
"call_number": "" },
|
||||||
|
"tp_sc_addr": { "length": 8, "ton_npi": { "ext": True, "type_of_number": "international",
|
||||||
|
"numbering_plan_id": "isdn_e164" },
|
||||||
|
"call_number": "4915790109999" },
|
||||||
|
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 4320 } ),
|
||||||
|
( 'e1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9',
|
||||||
|
{ "alpha_id": "", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
|
||||||
|
"tp_pid": True, "tp_dcs": True, "tp_vp": True },
|
||||||
|
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
|
||||||
|
"numbering_plan_id": "reserved_for_extension" },
|
||||||
|
"call_number": "" },
|
||||||
|
"tp_sc_addr": { "length": 8, "ton_npi": { "ext": True, "type_of_number": "international",
|
||||||
|
"numbering_plan_id": "isdn_e164" },
|
||||||
|
"call_number": "4915790109999" },
|
||||||
|
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 4320 } ),
|
||||||
( '454e6574776f726b73fffffffffffffff1ffffffffffffffffffffffffffffffffffffffffffffffff0000a7',
|
( '454e6574776f726b73fffffffffffffff1ffffffffffffffffffffffffffffffffffffffffffffffff0000a7',
|
||||||
{ "alpha_id": "ENetworks", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
|
{ "alpha_id": "ENetworks", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
|
||||||
"tp_pid": True, "tp_dcs": True, "tp_vp": True },
|
"tp_pid": True, "tp_dcs": True, "tp_vp": False },
|
||||||
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
|
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
|
||||||
"numbering_plan_id": "reserved_for_extension" },
|
"numbering_plan_id": "reserved_for_extension" },
|
||||||
"call_number": "" },
|
"call_number": "" },
|
||||||
@@ -251,6 +271,26 @@ class EF_SMSP(LinFixedEF):
|
|||||||
"numbering_plan_id": "reserved_for_extension" },
|
"numbering_plan_id": "reserved_for_extension" },
|
||||||
"call_number": "" },
|
"call_number": "" },
|
||||||
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 1440 } ),
|
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 1440 } ),
|
||||||
|
( 'fffffffffffffffffffffffffffffffffffffffffffffffffdffffffffffffffffffffffff07919403214365f7ffffffffffffff',
|
||||||
|
{ "alpha_id": "", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
|
||||||
|
"tp_pid": False, "tp_dcs": False, "tp_vp": False },
|
||||||
|
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
|
||||||
|
"numbering_plan_id": "reserved_for_extension" },
|
||||||
|
"call_number": "" },
|
||||||
|
"tp_sc_addr": { "length": 7, "ton_npi": { "ext": True, "type_of_number": "international",
|
||||||
|
"numbering_plan_id": "isdn_e164" },
|
||||||
|
"call_number": "49301234567" },
|
||||||
|
"tp_pid": b"\xff", "tp_dcs": b"\xff", "tp_vp_minutes": 635040 } ),
|
||||||
|
( 'fffffffffffffffffffffffffffffffffffffffffffffffffc0b919403214365f7ffffffff07919403214365f7ffffffffffffff',
|
||||||
|
{ "alpha_id": "", "parameter_indicators": { "tp_dest_addr": True, "tp_sc_addr": True,
|
||||||
|
"tp_pid": False, "tp_dcs": False, "tp_vp": False },
|
||||||
|
"tp_dest_addr": { "length": 11, "ton_npi": { "ext": True, "type_of_number": "international",
|
||||||
|
"numbering_plan_id": "isdn_e164" },
|
||||||
|
"call_number": "49301234567" },
|
||||||
|
"tp_sc_addr": { "length": 7, "ton_npi": { "ext": True, "type_of_number": "international",
|
||||||
|
"numbering_plan_id": "isdn_e164" },
|
||||||
|
"call_number": "49301234567" },
|
||||||
|
"tp_pid": b"\xff", "tp_dcs": b"\xff", "tp_vp_minutes": 635040 } ),
|
||||||
]
|
]
|
||||||
_test_no_pad = True
|
_test_no_pad = True
|
||||||
class ValidityPeriodAdapter(Adapter):
|
class ValidityPeriodAdapter(Adapter):
|
||||||
@@ -277,19 +317,64 @@ class EF_SMSP(LinFixedEF):
|
|||||||
else:
|
else:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sc_addr_len(ctx):
|
||||||
|
"""Compute the length field for an address field (see also: 3GPP TS 24.011, section 8.2.5.2)."""
|
||||||
|
if not hasattr(ctx, 'call_number') or len(ctx.call_number) == 0:
|
||||||
|
return 0xff
|
||||||
|
else:
|
||||||
|
# octets required for the call_number + one octet for ton_npi
|
||||||
|
return bytes_for_nibbles(len(ctx.call_number)) + 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dest_addr_len(ctx):
|
||||||
|
"""Compute the length field for an address field (see also: 3GPP TS 23.040, section 9.1.2.5)."""
|
||||||
|
if not hasattr(ctx, 'call_number') or len(ctx.call_number) == 0:
|
||||||
|
return 0xff
|
||||||
|
else:
|
||||||
|
# number of call_number digits
|
||||||
|
return len(ctx.call_number)
|
||||||
|
|
||||||
def __init__(self, fid='6f42', sfid=None, name='EF.SMSP', desc='Short message service parameters', **kwargs):
|
def __init__(self, fid='6f42', sfid=None, name='EF.SMSP', desc='Short message service parameters', **kwargs):
|
||||||
super().__init__(fid, sfid=sfid, name=name, desc=desc, rec_len=(28, None), **kwargs)
|
super().__init__(fid, sfid=sfid, name=name, desc=desc, rec_len=(28, None), **kwargs)
|
||||||
ScAddr = Struct('length'/Int8ub, 'ton_npi'/TonNpi, 'call_number'/BcdAdapter(Rpad(Bytes(10))))
|
ScAddr = Struct('length'/Rebuild(Int8ub, lambda ctx: EF_SMSP.sc_addr_len(ctx)),
|
||||||
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-28)))),
|
'ton_npi'/TonNpi, 'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))))
|
||||||
'parameter_indicators'/InvertAdapter(FlagsEnum(Byte, tp_dest_addr=1, tp_sc_addr=2,
|
DestAddr = Struct('length'/Rebuild(Int8ub, lambda ctx: EF_SMSP.dest_addr_len(ctx)),
|
||||||
tp_pid=3, tp_dcs=4, tp_vp=5)),
|
'ton_npi'/TonNpi, 'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))))
|
||||||
'tp_dest_addr'/ScAddr,
|
# (see comment below)
|
||||||
|
self._construct = Struct('alpha_id'/GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-28))),
|
||||||
|
'parameter_indicators'/InvertAdapter(BitStruct(
|
||||||
|
Const(7, BitsInteger(3)),
|
||||||
|
'tp_vp'/Flag,
|
||||||
|
'tp_dcs'/Flag,
|
||||||
|
'tp_pid'/Flag,
|
||||||
|
'tp_sc_addr'/Flag,
|
||||||
|
'tp_dest_addr'/Flag)),
|
||||||
|
'tp_dest_addr'/DestAddr,
|
||||||
'tp_sc_addr'/ScAddr,
|
'tp_sc_addr'/ScAddr,
|
||||||
|
|
||||||
'tp_pid'/Bytes(1),
|
'tp_pid'/Bytes(1),
|
||||||
'tp_dcs'/Bytes(1),
|
'tp_dcs'/Bytes(1),
|
||||||
'tp_vp_minutes'/EF_SMSP.ValidityPeriodAdapter(Byte))
|
'tp_vp_minutes'/EF_SMSP.ValidityPeriodAdapter(Byte))
|
||||||
|
|
||||||
|
# Ensure 'alpha_id' is always present
|
||||||
|
def encode_record_hex(self, abstract_data: dict, record_nr: int, total_len: int = None) -> str:
|
||||||
|
# Problem: TS 51.011 Section 10.5.6 describes the 'alpha_id' field as optional. However, this is only true
|
||||||
|
# at the time when the record length of the file is set up in the file system. A card manufacturer may decide
|
||||||
|
# to remove the field by setting the record length to 28. Likewise, the card manaufacturer may also decide to
|
||||||
|
# set the field to a distinct length by setting the record length to a value greater than 28 (e.g. 14 bytes
|
||||||
|
# 'alpha_id' + 28 bytes). Due to the fixed nature of the record length, this eventually means that in practice
|
||||||
|
# 'alpha_id' is a mandatory field with a fixed length.
|
||||||
|
#
|
||||||
|
# Due to the problematic specification of 'alpha_id' as a pseudo-optional field at the beginning of a
|
||||||
|
# fixed-size memory, the construct definition in self._construct has been incorrectly implemented and the field
|
||||||
|
# has been marked as COptional. We may correct the problem by removing COptional. But to maintain compatibility,
|
||||||
|
# we then have to ensure that in case the field is not provided (None), it is set to an empty string ('').
|
||||||
|
#
|
||||||
|
# See also ts_31_102.py, class EF_OCI for a correct example.
|
||||||
|
if abstract_data['alpha_id'] is None:
|
||||||
|
abstract_data['alpha_id'] = ''
|
||||||
|
return super().encode_record_hex(abstract_data, record_nr, total_len)
|
||||||
|
|
||||||
# TS 51.011 Section 10.5.7
|
# TS 51.011 Section 10.5.7
|
||||||
class EF_SMSS(TransparentEF):
|
class EF_SMSS(TransparentEF):
|
||||||
class MemCapAdapter(Adapter):
|
class MemCapAdapter(Adapter):
|
||||||
@@ -365,7 +450,7 @@ class DF_TELECOM(CardDF):
|
|||||||
# TS 51.011 Section 10.3.1
|
# TS 51.011 Section 10.3.1
|
||||||
class EF_LP(TransRecEF):
|
class EF_LP(TransRecEF):
|
||||||
_test_de_encode = [
|
_test_de_encode = [
|
||||||
( "24", "24"),
|
( "24", ["24"] ),
|
||||||
]
|
]
|
||||||
def __init__(self, fid='6f05', sfid=None, name='EF.LP', size=(1, None), rec_len=1,
|
def __init__(self, fid='6f05', sfid=None, name='EF.LP', size=(1, None), rec_len=1,
|
||||||
desc='Language Preference'):
|
desc='Language Preference'):
|
||||||
@@ -422,8 +507,8 @@ class EF_IMSI(TransparentEF):
|
|||||||
# TS 51.011 Section 10.3.4
|
# TS 51.011 Section 10.3.4
|
||||||
class EF_PLMNsel(TransRecEF):
|
class EF_PLMNsel(TransRecEF):
|
||||||
_test_de_encode = [
|
_test_de_encode = [
|
||||||
( "22F860", { "mcc": "228", "mnc": "06" } ),
|
( "22F860", [{ "mcc": "228", "mnc": "06" }] ),
|
||||||
( "330420", { "mcc": "334", "mnc": "020" } ),
|
( "330420", [{ "mcc": "334", "mnc": "020" }] ),
|
||||||
]
|
]
|
||||||
def __init__(self, fid='6f30', sfid=None, name='EF.PLMNsel', desc='PLMN selector',
|
def __init__(self, fid='6f30', sfid=None, name='EF.PLMNsel', desc='PLMN selector',
|
||||||
size=(24, None), rec_len=3, **kwargs):
|
size=(24, None), rec_len=3, **kwargs):
|
||||||
@@ -637,12 +722,12 @@ class EF_AD(TransparentEF):
|
|||||||
# TS 51.011 Section 10.3.20 / 10.3.22
|
# TS 51.011 Section 10.3.20 / 10.3.22
|
||||||
class EF_VGCS(TransRecEF):
|
class EF_VGCS(TransRecEF):
|
||||||
_test_de_encode = [
|
_test_de_encode = [
|
||||||
( "92f9ffff", "299fffff" ),
|
( "92f9ffff", ["299"] ),
|
||||||
]
|
]
|
||||||
def __init__(self, fid='6fb1', sfid=None, name='EF.VGCS', size=(4, 200), rec_len=4,
|
def __init__(self, fid='6fb1', sfid=None, name='EF.VGCS', size=(4, 200), rec_len=4,
|
||||||
desc='Voice Group Call Service', **kwargs):
|
desc='Voice Group Call Service', **kwargs):
|
||||||
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, rec_len=rec_len, **kwargs)
|
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, rec_len=rec_len, **kwargs)
|
||||||
self._construct = BcdAdapter(Bytes(4))
|
self._construct = PaddedBcdAdapter(Rpad(Bytes(4)))
|
||||||
|
|
||||||
# TS 51.011 Section 10.3.21 / 10.3.23
|
# TS 51.011 Section 10.3.21 / 10.3.23
|
||||||
class EF_VGCSS(TransparentEF):
|
class EF_VGCSS(TransparentEF):
|
||||||
@@ -773,9 +858,9 @@ class EF_LOCIGPRS(TransparentEF):
|
|||||||
# TS 51.011 Section 10.3.35..37
|
# TS 51.011 Section 10.3.35..37
|
||||||
class EF_xPLMNwAcT(TransRecEF):
|
class EF_xPLMNwAcT(TransRecEF):
|
||||||
_test_de_encode = [
|
_test_de_encode = [
|
||||||
( '62F2104000', { "mcc": "262", "mnc": "01", "act": [ "E-UTRAN NB-S1", "E-UTRAN WB-S1" ] } ),
|
( '62F2104000', [{ "mcc": "262", "mnc": "01", "act": [ "E-UTRAN NB-S1", "E-UTRAN WB-S1" ] }] ),
|
||||||
( '62F2108000', { "mcc": "262", "mnc": "01", "act": [ "UTRAN" ] } ),
|
( '62F2108000', [{ "mcc": "262", "mnc": "01", "act": [ "UTRAN" ] }] ),
|
||||||
( '62F220488C', { "mcc": "262", "mnc": "02", "act": ['E-UTRAN NB-S1', 'E-UTRAN WB-S1', 'EC-GSM-IoT', 'GSM', 'NG-RAN'] } ),
|
( '62F220488C', [{ "mcc": "262", "mnc": "02", "act": ['E-UTRAN NB-S1', 'E-UTRAN WB-S1', 'EC-GSM-IoT', 'GSM', 'NG-RAN'] }] ),
|
||||||
]
|
]
|
||||||
def __init__(self, fid='1234', sfid=None, name=None, desc=None, size=(40, None), rec_len=5, **kwargs):
|
def __init__(self, fid='1234', sfid=None, name=None, desc=None, size=(40, None), rec_len=5, **kwargs):
|
||||||
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, rec_len=rec_len, **kwargs)
|
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, rec_len=rec_len, **kwargs)
|
||||||
@@ -1010,9 +1095,10 @@ class EF_ICCID(TransparentEF):
|
|||||||
# TS 102 221 Section 13.3 / TS 31.101 Section 13 / TS 51.011 Section 10.1.2
|
# TS 102 221 Section 13.3 / TS 31.101 Section 13 / TS 51.011 Section 10.1.2
|
||||||
class EF_PL(TransRecEF):
|
class EF_PL(TransRecEF):
|
||||||
_test_de_encode = [
|
_test_de_encode = [
|
||||||
( '6465', "de" ),
|
( '6465', ["de"] ),
|
||||||
( '656e', "en" ),
|
( '656e', ["en"] ),
|
||||||
( 'ffff', None ),
|
( 'ffff', [None] ),
|
||||||
|
( '656e64657275ffffffff', ["en", "de", "ru", None, None] ),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, fid='2f05', sfid=0x05, name='EF.PL', desc='Preferred Languages'):
|
def __init__(self, fid='2f05', sfid=0x05, name='EF.PL', desc='Preferred Languages'):
|
||||||
@@ -1093,8 +1179,8 @@ class DF_GSM(CardDF):
|
|||||||
EF_MBI(),
|
EF_MBI(),
|
||||||
EF_MWIS(),
|
EF_MWIS(),
|
||||||
EF_CFIS(),
|
EF_CFIS(),
|
||||||
EF_EXT('6fc8', None, 'EF.EXT6', desc='Externsion6 (MBDN)'),
|
EF_EXT('6fc8', None, 'EF.EXT6', desc='Extension6 (MBDN)'),
|
||||||
EF_EXT('6fcc', None, 'EF.EXT7', desc='Externsion7 (CFIS)'),
|
EF_EXT('6fcc', None, 'EF.EXT7', desc='Extension7 (CFIS)'),
|
||||||
EF_SPDI(),
|
EF_SPDI(),
|
||||||
EF_MMSN(),
|
EF_MMSN(),
|
||||||
EF_EXT('6fcf', None, 'EF.EXT8', desc='Extension8 (MMSN)'),
|
EF_EXT('6fcf', None, 'EF.EXT8', desc='Extension8 (MMSN)'),
|
||||||
@@ -1177,9 +1263,11 @@ class CardProfileSIM(CardProfile):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_select_response(resp_hex: str) -> object:
|
def decode_select_response(resp_hex: str) -> object:
|
||||||
# we try to build something that resembles a dict resulting from the TLV decoder
|
"""
|
||||||
# of TS 102.221 (FcpTemplate), so that higher-level code only has to deal with one
|
Decode the select response to a dict representation, similar to the one of TS 102.221 (see ts_102_221.py,
|
||||||
# format of SELECT response
|
class FcpTemplate), so that higher-level code only has to deal with one respresentation. See also
|
||||||
|
3GPP TS 51.011, section 9.2.1
|
||||||
|
"""
|
||||||
resp_bin = h2b(resp_hex)
|
resp_bin = h2b(resp_hex)
|
||||||
struct_of_file_map = {
|
struct_of_file_map = {
|
||||||
0: 'transparent',
|
0: 'transparent',
|
||||||
@@ -1217,13 +1305,24 @@ class CardProfileSIM(CardProfile):
|
|||||||
record_len = resp_bin[14]
|
record_len = resp_bin[14]
|
||||||
ret['file_descriptor']['record_len'] = record_len
|
ret['file_descriptor']['record_len'] = record_len
|
||||||
ret['file_descriptor']['num_of_rec'] = ret['file_size'] // record_len
|
ret['file_descriptor']['num_of_rec'] = ret['file_size'] // record_len
|
||||||
ret['access_conditions'] = b2h(resp_bin[8:10])
|
ret['access_conditions'] = b2h(resp_bin[8:11])
|
||||||
if resp_bin[11] & 0x01 == 0:
|
|
||||||
|
# Life cycle status integer, see also ETSI TS 102 221, table 11.7b
|
||||||
|
lcsi = resp_bin[11]
|
||||||
|
if lcsi == 0x00:
|
||||||
|
ret['life_cycle_status_int'] = 'no_information'
|
||||||
|
elif lcsi == 0x01:
|
||||||
|
ret['life_cycle_status_int'] = 'creation'
|
||||||
|
elif lcsi == 0x03:
|
||||||
|
ret['life_cycle_status_int'] = 'initialization'
|
||||||
|
elif lcsi & 0xFD == 0x05:
|
||||||
ret['life_cycle_status_int'] = 'operational_activated'
|
ret['life_cycle_status_int'] = 'operational_activated'
|
||||||
elif resp_bin[11] & 0x04:
|
elif lcsi & 0xFD == 0x04:
|
||||||
ret['life_cycle_status_int'] = 'operational_deactivated'
|
ret['life_cycle_status_int'] = 'operational_deactivated'
|
||||||
|
elif lcsi & 0xFC == 0x0C:
|
||||||
|
ret['life_cycle_status_int'] = 'termination'
|
||||||
else:
|
else:
|
||||||
ret['life_cycle_status_int'] = 'terminated'
|
ret['life_cycle_status_int'] = lcsi
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+20
-11
@@ -139,7 +139,6 @@ def enc_plmn(mcc: Hexstr, mnc: Hexstr) -> Hexstr:
|
|||||||
|
|
||||||
def dec_plmn(threehexbytes: Hexstr) -> dict:
|
def dec_plmn(threehexbytes: Hexstr) -> dict:
|
||||||
res = {'mcc': "0", 'mnc': "0"}
|
res = {'mcc': "0", 'mnc': "0"}
|
||||||
dec_mcc_from_plmn_str(threehexbytes)
|
|
||||||
res['mcc'] = dec_mcc_from_plmn_str(threehexbytes)
|
res['mcc'] = dec_mcc_from_plmn_str(threehexbytes)
|
||||||
res['mnc'] = dec_mnc_from_plmn_str(threehexbytes)
|
res['mnc'] = dec_mnc_from_plmn_str(threehexbytes)
|
||||||
return res
|
return res
|
||||||
@@ -526,6 +525,13 @@ def expand_hex(hexstring, length):
|
|||||||
# no change
|
# no change
|
||||||
return hexstring
|
return hexstring
|
||||||
|
|
||||||
|
def bytes_for_nibbles(num_nibbles: int) -> int:
|
||||||
|
"""compute the number of bytes needed to store the given number of nibbles."""
|
||||||
|
n_bytes = num_nibbles // 2
|
||||||
|
if num_nibbles & 1:
|
||||||
|
n_bytes += 1
|
||||||
|
return n_bytes
|
||||||
|
|
||||||
|
|
||||||
def boxed_heading_str(heading, width=80):
|
def boxed_heading_str(heading, width=80):
|
||||||
"""Generate a string that contains a boxed heading."""
|
"""Generate a string that contains a boxed heading."""
|
||||||
@@ -624,15 +630,17 @@ def decomposeATR(atr_txt):
|
|||||||
Returns:
|
Returns:
|
||||||
dictionary of field and values
|
dictionary of field and values
|
||||||
|
|
||||||
>>> decomposeATR("3B A7 00 40 18 80 65 A2 08 01 01 52")
|
Example::
|
||||||
{ 'T0': {'value': 167},
|
|
||||||
'TB': {1: {'value': 0}},
|
>>> decomposeATR("3B A7 00 40 18 80 65 A2 08 01 01 52")
|
||||||
'TC': {2: {'value': 24}},
|
{ 'T0': {'value': 167},
|
||||||
'TD': {1: {'value': 64}},
|
'TB': {1: {'value': 0}},
|
||||||
'TS': {'value': 59},
|
'TC': {2: {'value': 24}},
|
||||||
'atr': [59, 167, 0, 64, 24, 128, 101, 162, 8, 1, 1, 82],
|
'TD': {1: {'value': 64}},
|
||||||
'hb': {'value': [128, 101, 162, 8, 1, 1, 82]},
|
'TS': {'value': 59},
|
||||||
'hbn': 7}
|
'atr': [59, 167, 0, 64, 24, 128, 101, 162, 8, 1, 1, 82],
|
||||||
|
'hb': {'value': [128, 101, 162, 8, 1, 1, 82]},
|
||||||
|
'hbn': 7}
|
||||||
"""
|
"""
|
||||||
ATR_PROTOCOL_TYPE_T0 = 0
|
ATR_PROTOCOL_TYPE_T0 = 0
|
||||||
atr_txt = normalizeATR(atr_txt)
|
atr_txt = normalizeATR(atr_txt)
|
||||||
@@ -902,7 +910,8 @@ class DataObjectCollection:
|
|||||||
def encode(self, decoded) -> bytes:
|
def encode(self, decoded) -> bytes:
|
||||||
res = bytearray()
|
res = bytearray()
|
||||||
for i in decoded:
|
for i in decoded:
|
||||||
obj = self.members_by_name(i[0])
|
name = i[0]
|
||||||
|
obj = self.members_by_name[name]
|
||||||
res.append(obj.to_tlv())
|
res.append(obj.to_tlv())
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[tool.pylint.main]
|
[tool.pylint.main]
|
||||||
ignored-classes = ["twisted.internet.reactor"]
|
ignored-classes = ["twisted.internet.reactor"]
|
||||||
|
|
||||||
|
[tool.pylint.TYPECHECK]
|
||||||
|
# SdKey subclasses are generated dynamically via SdKey.generate_sd_key_classes()
|
||||||
|
generated-members = ["SdKey[A-Za-z0-9]+"]
|
||||||
|
|||||||
+4
-3
@@ -1,12 +1,12 @@
|
|||||||
pyscard
|
pyscard
|
||||||
pyserial
|
pyserial
|
||||||
pytlv
|
pytlv
|
||||||
cmd2>=2.6.2,<3.0
|
cmd2>=2.6.2,<4.0
|
||||||
jsonpath-ng
|
jsonpath-ng
|
||||||
construct>=2.10.70
|
construct>=2.10.70
|
||||||
bidict
|
bidict
|
||||||
pyosmocom>=0.0.9
|
pyosmocom>=0.0.12
|
||||||
pyyaml>=5.1
|
pyyaml>=5.4
|
||||||
termcolor
|
termcolor
|
||||||
colorlog
|
colorlog
|
||||||
pycryptodomex
|
pycryptodomex
|
||||||
@@ -15,3 +15,4 @@ git+https://github.com/osmocom/asn1tools
|
|||||||
packaging
|
packaging
|
||||||
git+https://github.com/hologram-io/smpp.pdu
|
git+https://github.com/hologram-io/smpp.pdu
|
||||||
smpp.twisted3 @ git+https://github.com/jookies/smpp.twisted
|
smpp.twisted3 @ git+https://github.com/jookies/smpp.twisted
|
||||||
|
smpplib
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ setup(
|
|||||||
"pyscard",
|
"pyscard",
|
||||||
"pyserial",
|
"pyserial",
|
||||||
"pytlv",
|
"pytlv",
|
||||||
"cmd2 >= 1.5.0, < 3.0",
|
"cmd2 >= 2.6.2, < 4.0",
|
||||||
"jsonpath-ng",
|
"jsonpath-ng",
|
||||||
"construct >= 2.10.70",
|
"construct >= 2.10.70",
|
||||||
"bidict",
|
"bidict",
|
||||||
"pyosmocom >= 0.0.9",
|
"pyosmocom >= 0.0.12",
|
||||||
"pyyaml >= 5.1",
|
"pyyaml >= 5.4",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"colorlog",
|
"colorlog",
|
||||||
"pycryptodomex",
|
"pycryptodomex",
|
||||||
@@ -55,6 +55,10 @@ setup(
|
|||||||
"service-identity",
|
"service-identity",
|
||||||
"pyopenssl",
|
"pyopenssl",
|
||||||
"requests",
|
"requests",
|
||||||
|
"smpplib",
|
||||||
|
],
|
||||||
|
"CardKeyProviderPgsql": [
|
||||||
|
"psycopg2-binary",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-3
@@ -2200,9 +2200,9 @@ update_record 6 fe0112ffb53e96e5ff99731d51ad7beafd0e23ffffffffffffffffffffffffff
|
|||||||
update_record 7 fe02101da012f436d06824ecdd15050419ff9affffffffffffffffffffffffffffffff
|
update_record 7 fe02101da012f436d06824ecdd15050419ff9affffffffffffffffffffffffffffffff
|
||||||
update_record 8 fe02116929a373388ac904aff57ff57f6b3431ffffffffffffffffffffffffffffffff
|
update_record 8 fe02116929a373388ac904aff57ff57f6b3431ffffffffffffffffffffffffffffffff
|
||||||
update_record 9 fe0212a99245a5dc814e2f4c1aa908e9946e03ffffffffffffffffffffffffffffffff
|
update_record 9 fe0212a99245a5dc814e2f4c1aa908e9946e03ffffffffffffffffffffffffffffffff
|
||||||
update_record 10 fe0310521312c05a9aea93d70d44405172a580ffffffffffffffffffffffffffffffff
|
update_record 10 fe03601111111111111111111111111111111111111111111111111111111111111111
|
||||||
update_record 11 fe0311a9e45c72d45abde7db74261ee0c11b1bffffffffffffffffffffffffffffffff
|
update_record 11 fe03612222222222222222222222222222222222222222222222222222222222222222
|
||||||
update_record 12 fe0312867ba36b5873d60ea8b2cdcf3c0ddddaffffffffffffffffffffffffffffffff
|
update_record 12 fe03623333333333333333333333333333333333333333333333333333333333333333
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
# MF/DF.SYSTEM/EF.SIM_AUTH_COUNTER #
|
# MF/DF.SYSTEM/EF.SIM_AUTH_COUNTER #
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
Using PC/SC reader interface
|
INFO: Using PC/SC reader interface
|
||||||
Reading ...
|
Reading ...
|
||||||
Autodetected card type: Fairwaves-SIM
|
Autodetected card type: Fairwaves-SIM
|
||||||
ICCID: 8988219000000117833
|
ICCID: 8988219000000117833
|
||||||
IMSI: 001010000000111
|
IMSI: 001010000000111
|
||||||
GID1: ffffffffffffffff
|
GID1: ffffffffffffffff
|
||||||
GID2: ffffffffffffffff
|
GID2: ffffffffffffffff
|
||||||
SMSP: e1ffffffffffffffffffffffff0581005155f5ffffffffffff000000ffffffffffffffffffffffffffff
|
SMSP: ffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
||||||
|
SMSC: 0015555
|
||||||
SPN: Fairwaves
|
SPN: Fairwaves
|
||||||
Show in HPLMN: False
|
Show in HPLMN: False
|
||||||
Hide in OPLMN: False
|
Hide in OPLMN: False
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
Using PC/SC reader interface
|
INFO: Using PC/SC reader interface
|
||||||
Reading ...
|
Reading ...
|
||||||
Autodetected card type: Wavemobile-SIM
|
Autodetected card type: Wavemobile-SIM
|
||||||
ICCID: 89445310150011013678
|
ICCID: 89445310150011013678
|
||||||
IMSI: 001010000000102
|
IMSI: 001010000000102
|
||||||
GID1: Can't read file -- SW match failed! Expected 9000 and got 6a82.
|
GID1: Can't read file -- SW match failed! Expected 9000 and got 6a82.
|
||||||
GID2: Can't read file -- SW match failed! Expected 9000 and got 6a82.
|
GID2: Can't read file -- SW match failed! Expected 9000 and got 6a82.
|
||||||
SMSP: e1ffffffffffffffffffffffff0581005155f5ffffffffffff000000ffffffffffffffffffffffffffff
|
SMSP: ffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
||||||
|
SMSC: 0015555
|
||||||
SPN: wavemobile
|
SPN: wavemobile
|
||||||
Show in HPLMN: False
|
Show in HPLMN: False
|
||||||
Hide in OPLMN: False
|
Hide in OPLMN: False
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Using PC/SC reader interface
|
INFO: Using PC/SC reader interface
|
||||||
Reading ...
|
Reading ...
|
||||||
Autodetected card type: fakemagicsim
|
Autodetected card type: fakemagicsim
|
||||||
ICCID: 1122334455667788990
|
ICCID: 1122334455667788990
|
||||||
@@ -6,6 +6,7 @@ IMSI: 001010000000102
|
|||||||
GID1: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
GID1: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
||||||
GID2: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
GID2: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
||||||
SMSP: ffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
SMSP: ffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
||||||
|
SMSC: 0015555
|
||||||
SPN: Magic
|
SPN: Magic
|
||||||
Show in HPLMN: True
|
Show in HPLMN: True
|
||||||
Hide in OPLMN: False
|
Hide in OPLMN: False
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Utility to verify the functionality of pySim-prog.py
|
# Utility to verify the functionality of pySim-prog.py
|
||||||
#
|
#
|
||||||
# (C) 2018 by Sysmocom s.f.m.c. GmbH
|
# (C) 2018 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# Author: Philipp Maier
|
# Author: Philipp Maier
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Using PC/SC reader interface
|
INFO: Using PC/SC reader interface
|
||||||
Reading ...
|
Reading ...
|
||||||
Autodetected card type: sysmoISIM-SJA2
|
Autodetected card type: sysmoISIM-SJA2
|
||||||
ICCID: 8988211000000467343
|
ICCID: 8988211000000467343
|
||||||
@@ -6,6 +6,7 @@ IMSI: 001010000000102
|
|||||||
GID1: ffffffffffffffffffff
|
GID1: ffffffffffffffffffff
|
||||||
GID2: ffffffffffffffffffff
|
GID2: ffffffffffffffffffff
|
||||||
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
||||||
|
SMSC: 0015555
|
||||||
SPN: Magic
|
SPN: Magic
|
||||||
Show in HPLMN: True
|
Show in HPLMN: True
|
||||||
Hide in OPLMN: True
|
Hide in OPLMN: True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Using PC/SC reader interface
|
INFO: Using PC/SC reader interface
|
||||||
Reading ...
|
Reading ...
|
||||||
Autodetected card type: sysmoISIM-SJA5
|
Autodetected card type: sysmoISIM-SJA5
|
||||||
ICCID: 8949440000001155314
|
ICCID: 8949440000001155314
|
||||||
@@ -6,6 +6,7 @@ IMSI: 001010000000102
|
|||||||
GID1: ffffffffffffffffffff
|
GID1: ffffffffffffffffffff
|
||||||
GID2: ffffffffffffffffffff
|
GID2: ffffffffffffffffffff
|
||||||
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
||||||
|
SMSC: 0015555
|
||||||
SPN: Magic
|
SPN: Magic
|
||||||
Show in HPLMN: True
|
Show in HPLMN: True
|
||||||
Hide in OPLMN: True
|
Hide in OPLMN: True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Using PC/SC reader interface
|
INFO: Using PC/SC reader interface
|
||||||
Reading ...
|
Reading ...
|
||||||
Autodetected card type: sysmoUSIM-SJS1
|
Autodetected card type: sysmoUSIM-SJS1
|
||||||
ICCID: 8988211320300000028
|
ICCID: 8988211320300000028
|
||||||
@@ -6,6 +6,7 @@ IMSI: 001010000000102
|
|||||||
GID1: ffffffffffffffffffff
|
GID1: ffffffffffffffffffff
|
||||||
GID2: ffffffffffffffffffff
|
GID2: ffffffffffffffffffff
|
||||||
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
||||||
|
SMSC: 0015555
|
||||||
SPN: Magic
|
SPN: Magic
|
||||||
Show in HPLMN: True
|
Show in HPLMN: True
|
||||||
Hide in OPLMN: True
|
Hide in OPLMN: True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Using PC/SC reader interface
|
INFO: Using PC/SC reader interface
|
||||||
Reading ...
|
Reading ...
|
||||||
Autodetected card type: sysmosim-gr1
|
Autodetected card type: sysmosim-gr1
|
||||||
ICCID: 2222334455667788990
|
ICCID: 2222334455667788990
|
||||||
@@ -6,6 +6,7 @@ IMSI: 001010000000102
|
|||||||
GID1: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
GID1: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
||||||
GID2: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
GID2: Can't read file -- SW match failed! Expected 9000 and got 9404.
|
||||||
SMSP: ffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
SMSP: ffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
|
||||||
|
SMSC: 0015555
|
||||||
SPN: Not available
|
SPN: Not available
|
||||||
Show in HPLMN: False
|
Show in HPLMN: False
|
||||||
Hide in OPLMN: False
|
Hide in OPLMN: False
|
||||||
|
|||||||
@@ -7,10 +7,24 @@ set apdu_strict true
|
|||||||
# No command data field, No response data field present
|
# No command data field, No response data field present
|
||||||
apdu 00700001 --expect-sw 9000 --expect-response-regex '^$'
|
apdu 00700001 --expect-sw 9000 --expect-response-regex '^$'
|
||||||
|
|
||||||
|
# Case #1: (verify pin)
|
||||||
|
# This command returns the number of remaining authentication attempts in the
|
||||||
|
# form of a status that has the form 63cX, where X is the number of remaining
|
||||||
|
# attempts. Such a status word can be easily confused with the response to a
|
||||||
|
# case #4 APDU. This test checks if the transport layer correctly distinguishes
|
||||||
|
# the between APDU case #1 and APDU case #4.
|
||||||
|
apdu 0020000A --expect-sw 63c? --expect-response-regex '^$'
|
||||||
|
|
||||||
# Case #2: (status)
|
# Case #2: (status)
|
||||||
# No command data field, Response data field present
|
# No command data field, Response data field present
|
||||||
apdu 80F2000000 --expect-sw 9000 --expect-response-regex '^[a-fA-F0-9]+$'
|
apdu 80F2000000 --expect-sw 9000 --expect-response-regex '^[a-fA-F0-9]+$'
|
||||||
|
|
||||||
|
# Case #2: (verify pin)
|
||||||
|
# (see also above). This test checks if the transport layer is also able to
|
||||||
|
# distinguish correctly between APDU case #2 (with zero length response) and
|
||||||
|
# APDU case #4.
|
||||||
|
apdu 0020000A00 --expect-sw 63c? --expect-response-regex '^$'
|
||||||
|
|
||||||
# Case #3: (terminal capability)
|
# Case #3: (terminal capability)
|
||||||
# Command data field present, No response data field
|
# Command data field present, No response data field
|
||||||
apdu 80AA000005a903830180 --expect-sw 9000 --expect-response-regex '^$'
|
apdu 80AA000005a903830180 --expect-sw 9000 --expect-response-regex '^$'
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"profile_info": {
|
"profile_info": {
|
||||||
"iccid": "8949449999999990031f",
|
"iccid": "8949449999999990031",
|
||||||
"isdp_aid": "a0000005591010ffffffff8900001200",
|
"isdp_aid": "a0000005591010ffffffff8900001200",
|
||||||
"profile_state": "disabled",
|
"profile_state": "disabled",
|
||||||
"service_provider_name": "OsmocomSPN",
|
"service_provider_name": "OsmocomSPN",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
from utils import *
|
from utils import *
|
||||||
|
|
||||||
# This testcase requires a sysmoEUICC1-C2T with the test prfile TS48V1-B-UNIQUE (ICCID 8949449999999990031f)
|
# This testcase requires a sysmoEUICC1-C2T with the test prfile TS48V1-B-UNIQUE (ICCID 8949449999999990031)
|
||||||
# installed, and in disabled state. Also the profile must be installed in such a way that notifications are
|
# installed, and in disabled state. Also the profile must be installed in such a way that notifications are
|
||||||
# generated when the profile is disabled or enabled (ProfileMetadata)
|
# generated when the profile is disabled or enabled (ProfileMetadata)
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ class test_case(UnittestUtils):
|
|||||||
self.runPySimShell(cardname, "test_enable_disable_profile.script")
|
self.runPySimShell(cardname, "test_enable_disable_profile.script")
|
||||||
self.assertEqualFiles("enable_disable_profile.tmp")
|
self.assertEqualFiles("enable_disable_profile.tmp")
|
||||||
|
|
||||||
def test_enable_disable_profile(self):
|
def test_set_nickname(self):
|
||||||
cardname = 'sysmoEUICC1-C2T'
|
cardname = 'sysmoEUICC1-C2T'
|
||||||
|
|
||||||
self.runPySimShell(cardname, "test_set_nickname.script")
|
self.runPySimShell(cardname, "test_set_nickname.script")
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ set echo true
|
|||||||
|
|
||||||
select ADF.ISD-R
|
select ADF.ISD-R
|
||||||
|
|
||||||
|
# Ensure that the test-profile we intend to test with is actually enabled
|
||||||
|
enable_profile --iccid 89000123456789012341
|
||||||
|
|
||||||
# by ICCID (pre-installed test profile on sysmoEUICC1-C2T)
|
# by ICCID (pre-installed test profile on sysmoEUICC1-C2T)
|
||||||
disable_profile --iccid 89000123456789012341 > enable_disable_profile.tmp
|
disable_profile --iccid 89000123456789012341 > enable_disable_profile.tmp
|
||||||
enable_profile --iccid 89000123456789012341 >> enable_disable_profile.tmp
|
enable_profile --iccid 89000123456789012341 >> enable_disable_profile.tmp
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ set echo true
|
|||||||
|
|
||||||
select ADF.ISD-R
|
select ADF.ISD-R
|
||||||
|
|
||||||
# Generate two (additional) notifications by quickly enabeling the test profile
|
# Ensure that the test-profile is actually enabled. (In case te test-profile
|
||||||
enable_profile --iccid 8949449999999990031f
|
# was disabled, a notification may be generated. The testcase should tolerate
|
||||||
|
# that)
|
||||||
|
enable_profile --iccid 89000123456789012341
|
||||||
|
|
||||||
|
# Generate two (additional) notifications by quickly enabeling the test profile
|
||||||
|
enable_profile --iccid 8949449999999990031
|
||||||
enable_profile --iccid 89000123456789012341
|
enable_profile --iccid 89000123456789012341
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
set debug true
|
set debug true
|
||||||
set echo true
|
set echo true
|
||||||
|
|
||||||
|
# The output of get_profiles_info will also include the "profile_state", which
|
||||||
|
# can be either "enabled" or "disabled". Ensure that the correct profile is
|
||||||
|
# enabled.
|
||||||
|
enable_profile --iccid 89000123456789012341
|
||||||
|
|
||||||
select ADF.ISD-R
|
select ADF.ISD-R
|
||||||
get_profiles_info > get_profiles_info.tmp
|
get_profiles_info > get_profiles_info.tmp
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"type_of_number": "reserved_for_extension",
|
"type_of_number": "reserved_for_extension",
|
||||||
"numbering_plan_id": "reserved_for_extension"
|
"numbering_plan_id": "reserved_for_extension"
|
||||||
},
|
},
|
||||||
"dialing_nr": "123456",
|
"dialing_nr": "1234567",
|
||||||
"cap_conf_id": 42,
|
"cap_conf_id": 42,
|
||||||
"ext4_record_id": 23
|
"ext4_record_id": 23
|
||||||
},
|
},
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"type_of_number": "reserved_for_extension",
|
"type_of_number": "reserved_for_extension",
|
||||||
"numbering_plan_id": "reserved_for_extension"
|
"numbering_plan_id": "reserved_for_extension"
|
||||||
},
|
},
|
||||||
"dialing_nr": "123456",
|
"dialing_nr": "1234567",
|
||||||
"cap_conf_id": 42,
|
"cap_conf_id": 42,
|
||||||
"ext4_record_id": 23
|
"ext4_record_id": 23
|
||||||
},
|
},
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"type_of_number": "reserved_for_extension",
|
"type_of_number": "reserved_for_extension",
|
||||||
"numbering_plan_id": "reserved_for_extension"
|
"numbering_plan_id": "reserved_for_extension"
|
||||||
},
|
},
|
||||||
"dialing_nr": "123456",
|
"dialing_nr": "1234567",
|
||||||
"cap_conf_id": 42,
|
"cap_conf_id": 42,
|
||||||
"ext4_record_id": 23
|
"ext4_record_id": 23
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
"type_of_number": "reserved_for_extension",
|
"type_of_number": "reserved_for_extension",
|
||||||
"numbering_plan_id": "reserved_for_extension"
|
"numbering_plan_id": "reserved_for_extension"
|
||||||
},
|
},
|
||||||
"dialing_nr": "123456",
|
"dialing_nr": "1234567",
|
||||||
"cap_conf_id": 42,
|
"cap_conf_id": 42,
|
||||||
"ext4_record_id": 23
|
"ext4_record_id": 23
|
||||||
}
|
}
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Utility to verify the functionality of pySim-smpp2sim.py
|
||||||
|
#
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Philipp Maier
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
PYSIM_SHELL=./pySim-shell.py
|
||||||
|
PYSIM_SHELL_LOG=./pySim-shell.log
|
||||||
|
PYSIM_SMPP2SIM=./pySim-smpp2sim.py
|
||||||
|
PYSIM_SMPP2SIM_LOG=./pySim-smpp2sim.log
|
||||||
|
PYSIM_SMPP2SIM_PORT=2775
|
||||||
|
PYSIM_SMPP2SIM_TIMEOUT=10
|
||||||
|
PYSIM_SMPPOTATOOL=./contrib/smpp-ota-tool.py
|
||||||
|
PYSIM_SMPPOTATOOL_LOG=./smpp-ota-tool.log
|
||||||
|
|
||||||
|
function dump_logs {
|
||||||
|
echo ""
|
||||||
|
echo "$PYSIM_SMPPOTATOOL_LOG"
|
||||||
|
echo "------------8<------------"
|
||||||
|
cat $PYSIM_SMPPOTATOOL_LOG
|
||||||
|
echo "------------8<------------"
|
||||||
|
echo ""
|
||||||
|
echo "$PYSIM_SMPP2SIM_LOG"
|
||||||
|
echo "------------8<------------"
|
||||||
|
cat $PYSIM_SMPP2SIM_LOG
|
||||||
|
echo "------------8<------------"
|
||||||
|
}
|
||||||
|
|
||||||
|
function send_test_request {
|
||||||
|
echo ""
|
||||||
|
echo "Sending request to SMPP server:"
|
||||||
|
C_APDU=$1
|
||||||
|
R_APDU_EXPECTED=$2
|
||||||
|
|
||||||
|
echo "Sending: $C_APDU"
|
||||||
|
COMMANDLINE="$PYSIM_SMPPOTATOOL --verbose --port $PYSIM_SMPP2SIM_PORT --kic $KIC --kid $KID --kic-idx $KEY_INDEX --kid-idx $KEY_INDEX --algo-crypt $ALGO_CRYPT --algo-auth $ALGO_AUTH --tar $TAR --apdu $C_APDU"
|
||||||
|
echo "Commandline: $COMMANDLINE"
|
||||||
|
R_APDU=`$COMMANDLINE 2> $PYSIM_SMPPOTATOOL_LOG`
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Unable to send request! -- failed!"
|
||||||
|
dump_logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Got response from SMPP server:"
|
||||||
|
echo "Sent: $C_APDU"
|
||||||
|
echo "Received: $R_APDU"
|
||||||
|
echo "Expected: $R_APDU_EXPECTED"
|
||||||
|
if [ "$R_APDU" != "$R_APDU_EXPECTED" ]; then
|
||||||
|
echo "Response does not match the expected response! -- failed!"
|
||||||
|
dump_logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Response matches the expected response -- success!"
|
||||||
|
}
|
||||||
|
|
||||||
|
function start_smpp_server {
|
||||||
|
PCSC_READER=$1
|
||||||
|
echo ""
|
||||||
|
echo "Starting SMPP server:"
|
||||||
|
|
||||||
|
# Start the SMPP server
|
||||||
|
COMMANDLINE="$PYSIM_SMPP2SIM -p $PCSC_READER --smpp-bind-port $PYSIM_SMPP2SIM_PORT --apdu-trace"
|
||||||
|
echo "Commandline: $COMMANDLINE"
|
||||||
|
$COMMANDLINE > $PYSIM_SMPP2SIM_LOG 2>&1 &
|
||||||
|
PYSIM_SMPP2SIM_PID=$!
|
||||||
|
trap 'kill $PYSIM_SMPP2SIM_PID' EXIT
|
||||||
|
echo "SMPP server started (PID=$PYSIM_SMPP2SIM_PID)"
|
||||||
|
|
||||||
|
# Wait until the SMPP server is reachable
|
||||||
|
RC=1
|
||||||
|
RETRY_COUNT=0
|
||||||
|
while [ $RC -ne 0 ]; do
|
||||||
|
nc -z localhost $PYSIM_SMPP2SIM_PORT
|
||||||
|
RC=$?
|
||||||
|
((RETRY_COUNT++))
|
||||||
|
if [ $RETRY_COUNT -gt $PYSIM_SMPP2SIM_TIMEOUT ]; then
|
||||||
|
echo "SMPP server not reachable (port=$PYSIM_SMPP2SIM_PORT) -- abort"
|
||||||
|
dump_logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "SMPP server reachable (port=$PYSIM_SMPP2SIM_PORT)"
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop_smpp_server {
|
||||||
|
echo ""
|
||||||
|
echo "Stopping SMPP server:"
|
||||||
|
kill $PYSIM_SMPP2SIM_PID
|
||||||
|
echo "SMPP server stopped (PID=$PYSIM_SMPP2SIM_PID)"
|
||||||
|
trap EXIT
|
||||||
|
}
|
||||||
|
|
||||||
|
function find_card_by_iccid_or_eid {
|
||||||
|
ICCID=$1
|
||||||
|
EID=$2
|
||||||
|
echo ""
|
||||||
|
echo "Searching for card:"
|
||||||
|
echo "ICCID: \"$ICCID\""
|
||||||
|
if [ -n "$EID" ]; then
|
||||||
|
echo "EID: \"$EID\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine number of available PCSC readers
|
||||||
|
PCSC_READER_COUNT=`pcsc_scan -rn | wc -l`
|
||||||
|
|
||||||
|
# In case an EID is set, search for a card with that EID first
|
||||||
|
if [ -n "$EID" ]; then
|
||||||
|
for PCSC_READER in $(seq 0 $(($PCSC_READER_COUNT-1))); do
|
||||||
|
echo "probing card (eID) in reader $PCSC_READER ..."
|
||||||
|
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select ADF.ISD-R" -e "get_eid" 2> /dev/null | tail -3`
|
||||||
|
echo $RESULT_JSON | grep $EID > /dev/null
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Found card (eID) in reader $PCSC_READER"
|
||||||
|
return $PCSC_READER
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Search for card with the given ICCID
|
||||||
|
if [ -z "$ICCID" ]; then
|
||||||
|
echo "invalid ICCID, zero length ICCID is not allowed! -- abort"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for PCSC_READER in $(seq 0 $(($PCSC_READER_COUNT-1))); do
|
||||||
|
echo "probing card (ICCID) in reader $PCSC_READER ..."
|
||||||
|
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select EF.ICCID" -e "read_binary_decoded" 2> /dev/null | tail -3`
|
||||||
|
echo $RESULT_JSON | grep $ICCID > /dev/null
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Found card (by ICCID) in reader $PCSC_READER"
|
||||||
|
return $PCSC_READER
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Card not found -- abort"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function enable_profile {
|
||||||
|
PCSC_READER=$1
|
||||||
|
ICCID=$2
|
||||||
|
EID=$3
|
||||||
|
if [ -z "$EID" ]; then
|
||||||
|
# This is no eUICC, nothing to enable
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if the profile is already enabled
|
||||||
|
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select EF.ICCID" -e "read_binary_decoded" 2> /dev/null | tail -3`
|
||||||
|
ICCID_ENABLED=`echo $RESULT_JSON | jq -r '.iccid'`
|
||||||
|
if [ $ICCID != $ICCID_ENABLED ]; then
|
||||||
|
# Disable the currentle enabled profile
|
||||||
|
echo ""
|
||||||
|
echo "Disabeling currently enabled profile:"
|
||||||
|
echo "ICCID: \"$ICCID\""
|
||||||
|
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select ADF.ISD-R" -e "disable_profile --iccid $ICCID_ENABLED" 2> /dev/null | tail -3`
|
||||||
|
echo $RESULT_JSON | grep "ok" > /dev/null
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "unable to disable profile with \"$ICCID_ENABLED\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "profile disabled"
|
||||||
|
|
||||||
|
# Enable the profile we intend to test with
|
||||||
|
echo ""
|
||||||
|
echo "Enabeling profile:"
|
||||||
|
echo "ICCID: \"$ICCID\""
|
||||||
|
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select ADF.ISD-R" -e "enable_profile --iccid $ICCID" 2> /dev/null | tail -3`
|
||||||
|
echo $RESULT_JSON | grep "ok\|profileNotInDisabledState" > /dev/null
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "unable to enable profile with \"$ICCID\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "profile enabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
export PYTHONPATH=./
|
||||||
|
|
||||||
|
echo "pySim-smpp2sim_test - a test program to test pySim-smpp2sim.py"
|
||||||
|
echo "=============================================================="
|
||||||
|
|
||||||
|
TESTCASE_DIR=`dirname $0`
|
||||||
|
for TEST_CONFIG_FILE in $TESTCASE_DIR/testcase_*.cfg ; do
|
||||||
|
echo ""
|
||||||
|
echo "running testcase: $TEST_CONFIG_FILE"
|
||||||
|
. $TEST_CONFIG_FILE
|
||||||
|
find_card_by_iccid_or_eid $ICCID $EID
|
||||||
|
PCSC_READER=$?
|
||||||
|
enable_profile $PCSC_READER $ICCID $EID
|
||||||
|
start_smpp_server $PCSC_READER
|
||||||
|
send_test_request $APDU "$EXPECTED_RESPONSE"
|
||||||
|
stop_smpp_server
|
||||||
|
echo ""
|
||||||
|
echo "testcase ok"
|
||||||
|
echo "--------------------------------------------------------------"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "done."
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Preparation:
|
||||||
|
# This testcase executes against a sysmoISIM-SJA5 card. For the testcase, the
|
||||||
|
# key configuration on the card may be used as it is.
|
||||||
|
|
||||||
|
# Card parameter:
|
||||||
|
ICCID="8949440000001155314" # <-- change to the ICCID of your card!
|
||||||
|
EID=""
|
||||||
|
KIC='51D4FC44BCBA7C4589DFADA3297720AF' # <-- change to the KIC1 of your card!
|
||||||
|
KID='0449699C472CE71E2FB7B56245EF7684' # <-- change to the KID1 of your card!
|
||||||
|
KEY_INDEX=1
|
||||||
|
ALGO_CRYPT=triple_des_cbc2
|
||||||
|
ALGO_AUTH=triple_des_cbc2
|
||||||
|
TAR='B00010'
|
||||||
|
|
||||||
|
# Testcase: Send OTA-SMS that selects DF.GSM and returns the select response
|
||||||
|
APDU='A0A40000027F20A0C0000016'
|
||||||
|
EXPECTED_RESPONSE='0000ffff7f2002000000000009b106350400838a838a 9000'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Preparation:
|
||||||
|
# This testcase executes against a sysmoEUICC1-C2T, which is equipped with the
|
||||||
|
# TS48V1-B-UNIQUE test profile from https://test.rsp.sysmocom.de/ (Activation
|
||||||
|
# code: 1$smdpp.test.rsp.sysmocom.de$TS48V1-B-UNIQUE). This testprofile must be
|
||||||
|
# present on the eUICC before this testcase can be executed.
|
||||||
|
|
||||||
|
# Card parameter:
|
||||||
|
ICCID="8949449999999990031"
|
||||||
|
EID="89049044900000000000000000102355" # <-- change to the EID of your card!
|
||||||
|
KIC='66778899aabbccdd1122334455eeff10'
|
||||||
|
KID='112233445566778899aabbccddeeff10'
|
||||||
|
KEY_INDEX=2
|
||||||
|
ALGO_CRYPT=aes_cbc
|
||||||
|
ALGO_AUTH=aes_cmac
|
||||||
|
TAR='b00120'
|
||||||
|
|
||||||
|
# Testcase: Send OTA-SMS that selects DF.ICCID and returns the select response
|
||||||
|
APDU='00a40004022fe200C000001d'
|
||||||
|
EXPECTED_RESPONSE='621b8202412183022fe2a503d001408a01058b032f06038002000a8800 9000'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Preparation:
|
||||||
|
# This testcase executes against a sysmoISIM-SJA5 card. Since this card model is
|
||||||
|
# shipped with a classic DES key configuration, it is necessary to provision
|
||||||
|
# AES128 test keys before this testcase may be executed. The the following
|
||||||
|
# pySim-shell command sequence may be used:
|
||||||
|
#
|
||||||
|
# verify_adm 34173960 # <-- change to the ADM key of your card!
|
||||||
|
# select /DF.SYSTEM/EF.0348_KEY
|
||||||
|
# update_record 10 fe03601111111111111111111111111111111111111111111111111111111111111111
|
||||||
|
# update_record 11 fe03612222222222222222222222222222222222222222222222222222222222222222
|
||||||
|
# update_record 12 fe03623333333333333333333333333333333333333333333333333333333333333333
|
||||||
|
#
|
||||||
|
# This overwrites one of the already existing 3DES SCP02 key (KVN 47) and replaces it
|
||||||
|
# with an AES256 SCP80 key (KVN 3).
|
||||||
|
|
||||||
|
# Card parameter:
|
||||||
|
ICCID="8949440000001155314" # <-- change to the ICCID of your card!
|
||||||
|
EID=""
|
||||||
|
KIC='1111111111111111111111111111111111111111111111111111111111111111'
|
||||||
|
KID='2222222222222222222222222222222222222222222222222222222222222222'
|
||||||
|
KEY_INDEX=3
|
||||||
|
ALGO_CRYPT=aes_cbc
|
||||||
|
ALGO_AUTH=aes_cmac
|
||||||
|
TAR='B00010'
|
||||||
|
|
||||||
|
# Testcase: Send OTA-SMS that selects DF.GSM and returns the select response
|
||||||
|
APDU='A0A40000027F20A0C0000016'
|
||||||
|
EXPECTED_RESPONSE='0000ffff7f2002000000000009b106350400838a838a 9000'
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Utility to verify the functionality of pySim-trace.py
|
# Utility to verify the functionality of pySim-trace.py
|
||||||
#
|
#
|
||||||
# (C) 2023 by Sysmocom s.f.m.c. GmbH
|
# (C) 2023 by sysmocom - s.f.m.c. GmbH
|
||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
#
|
#
|
||||||
# Author: Philipp Maier
|
# Author: Philipp Maier
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ CardReset(3b9f96801f878031e073fe211b674a4c753034054ba9)
|
|||||||
===============================
|
===============================
|
||||||
00 SEARCH RECORD MF/ADF.USIM/EF.SMSP 01 9000 {"cmd": {"file": "currently_selected_ef", "mode": "forward_search", "record_number": 1, "search_string": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, "rsp": {"body": [2], "sw": "9000"}}
|
00 SEARCH RECORD MF/ADF.USIM/EF.SMSP 01 9000 {"cmd": {"file": "currently_selected_ef", "mode": "forward_search", "record_number": 1, "search_string": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, "rsp": {"body": [2], "sw": "9000"}}
|
||||||
===============================
|
===============================
|
||||||
00 READ RECORD MF/ADF.USIM/EF.SMSP 01 9000 {"alpha_id": "", "parameter_indicators": {"tp_dest_addr": false, "tp_sc_addr": true, "tp_pid": true, "tp_dcs": true, "tp_vp": true}, "tp_dest_addr": {"length": 255, "ton_npi": {"ext": true, "type_of_number": "reserved_for_extension", "numbering_plan_id": "reserved_for_extension"}, "call_number": ""}, "tp_sc_addr": {"length": 5, "ton_npi": {"ext": true, "type_of_number": "unknown", "numbering_plan_id": "isdn_e164"}, "call_number": "0015555f"}, "tp_pid": "00", "tp_dcs": "00", "tp_vp_minutes": 5}
|
00 READ RECORD MF/ADF.USIM/EF.SMSP 01 9000 {"alpha_id": "", "parameter_indicators": {"tp_vp": true, "tp_dcs": true, "tp_pid": true, "tp_sc_addr": true, "tp_dest_addr": false}, "tp_dest_addr": {"length": 255, "ton_npi": {"ext": true, "type_of_number": "reserved_for_extension", "numbering_plan_id": "reserved_for_extension"}, "call_number": ""}, "tp_sc_addr": {"length": 5, "ton_npi": {"ext": true, "type_of_number": "unknown", "numbering_plan_id": "isdn_e164"}, "call_number": "0015555"}, "tp_pid": "00", "tp_dcs": "00", "tp_vp_minutes": 5}
|
||||||
===============================
|
===============================
|
||||||
00 SEARCH RECORD MF/ADF.USIM/EF.SMS 01 9000 {"cmd": {"file": "currently_selected_ef", "mode": "forward_search", "record_number": 1, "search_string": "00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, "rsp": {"body": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], "sw": "9000"}}
|
00 SEARCH RECORD MF/ADF.USIM/EF.SMS 01 9000 {"cmd": {"file": "currently_selected_ef", "mode": "forward_search", "record_number": 1, "search_string": "00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, "rsp": {"body": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], "sw": "9000"}}
|
||||||
===============================
|
===============================
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../smdpp-data
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
|
||||||
|
#
|
||||||
|
# Author: Eric Wild
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from osmocom.utils import b2h, h2b
|
||||||
|
|
||||||
|
from pySim.sms import SMS_SUBMIT, AddressField
|
||||||
|
from pySim.cat import (ProactiveCommand, CommandDetails, DeviceIdentities,
|
||||||
|
BearerDescription, BufferSize, UiccTransportLevel,
|
||||||
|
OtherAddress, ChannelData, ChannelDataLength, ChannelStatus,
|
||||||
|
Result, LocationInformation)
|
||||||
|
|
||||||
|
from pySim.bip import Proact, ProactChannels, terminal_profile
|
||||||
|
|
||||||
|
|
||||||
|
class _EchoServer:
|
||||||
|
"""behold, my tiny threaded TCP echo server listening on 127.0.0.1:<port>"""
|
||||||
|
def __init__(self):
|
||||||
|
self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
self._srv.bind(('127.0.0.1', 0))
|
||||||
|
self._srv.listen(1)
|
||||||
|
self.port = self._srv.getsockname()[1]
|
||||||
|
self.accepted = threading.Event()
|
||||||
|
self._conns = []
|
||||||
|
self._stop = False
|
||||||
|
threading.Thread(target=self._run, daemon=True).start()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
try:
|
||||||
|
conn, _ = self._srv.accept()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
self._conns.append(conn)
|
||||||
|
self.accepted.set()
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
data = conn.recv(4096)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
conn.sendall(data)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._stop = True
|
||||||
|
for s in [self._srv] + self._conns:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _pcmd(children_tlvs):
|
||||||
|
"""Assemble D0 proactive-command TLV from child IE bytes,
|
||||||
|
decode it like transport does after a FETCH"""
|
||||||
|
body = b''.join(children_tlvs)
|
||||||
|
pdu = h2b('D0') + bytes([len(body)]) + body
|
||||||
|
return ProactiveCommand().from_tlv(pdu)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_channel(port, ip='127.0.0.1', cmd_nr=1):
|
||||||
|
a, b, c, d = (int(x) for x in ip.split('.'))
|
||||||
|
return _pcmd([
|
||||||
|
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'open_channel',
|
||||||
|
'command_qualifier': 3}).to_tlv(),
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv(),
|
||||||
|
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': b''}).to_tlv(),
|
||||||
|
BufferSize(decoded=1024).to_tlv(),
|
||||||
|
UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote',
|
||||||
|
'port_number': port}).to_tlv(),
|
||||||
|
OtherAddress(decoded={'type_of_address': 'ipv4',
|
||||||
|
'address': bytes([a, b, c, d])}).to_tlv(),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _open_channel_raw(extra_ies, cmd_nr=1):
|
||||||
|
"""OPEN CHANNEL with only the head data"""
|
||||||
|
return _pcmd([
|
||||||
|
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'open_channel',
|
||||||
|
'command_qualifier': 3}).to_tlv(),
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv(),
|
||||||
|
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': b''}).to_tlv(),
|
||||||
|
BufferSize(decoded=1024).to_tlv(),
|
||||||
|
] + extra_ies)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_data(payload, chan='channel_1', cmd_nr=1):
|
||||||
|
return _pcmd([
|
||||||
|
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'send_data',
|
||||||
|
'command_qualifier': 1}).to_tlv(),
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
|
||||||
|
ChannelData(decoded=b2h(payload)).to_tlv(),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _receive_data(length, chan='channel_1', cmd_nr=1):
|
||||||
|
return _pcmd([
|
||||||
|
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'receive_data',
|
||||||
|
'command_qualifier': 0}).to_tlv(),
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
|
||||||
|
ChannelDataLength(decoded=length).to_tlv(),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _close_channel(chan='channel_1', cmd_nr=1):
|
||||||
|
return _pcmd([
|
||||||
|
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'close_channel',
|
||||||
|
'command_qualifier': 0}).to_tlv(),
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _first(til, cls):
|
||||||
|
return next((x for x in til if isinstance(x, cls)), None)
|
||||||
|
|
||||||
|
|
||||||
|
class BipRelayRoundTripTest(unittest.TestCase):
|
||||||
|
"""Drive the fixed Proact handlers (blocking sockets) with synthetic
|
||||||
|
proactive commands against a local echo server and assert a byte round-trip
|
||||||
|
plus the channel bookkeeping / error handling."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.echo = _EchoServer()
|
||||||
|
self.addCleanup(self.echo.close)
|
||||||
|
self.events = []
|
||||||
|
self.proact = Proact(data_available_sink=self.events.append)
|
||||||
|
self.addCleanup(self._close_all_channels)
|
||||||
|
|
||||||
|
def _close_all_channels(self):
|
||||||
|
for chan in list(self.proact.channels.channels.values()):
|
||||||
|
try:
|
||||||
|
chan.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _open(self, cmd_nr=1):
|
||||||
|
til = self.proact.handle_OpenChannel(_open_channel(self.echo.port, cmd_nr=cmd_nr))
|
||||||
|
# every TLV in the response must serialise (the transport does exactly
|
||||||
|
# this to post the TERMINAL RESPONSE)
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
return til
|
||||||
|
|
||||||
|
def test_open_send_receive_roundtrip(self):
|
||||||
|
# OPEN CHANNEL -> socket connected, channel 1 opened, link established
|
||||||
|
til = self._open()
|
||||||
|
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
|
||||||
|
self.assertIn(1, self.proact.channels.channels)
|
||||||
|
cd = _first(til, CommandDetails)
|
||||||
|
self.assertEqual(cd.decoded['type_of_command'], 'open_channel')
|
||||||
|
# TS 102 223 6.8.2 TERMINAL RESPONSE device id: terminal -> UICC
|
||||||
|
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281')
|
||||||
|
# channel status: channel 1, link established
|
||||||
|
self.assertEqual(_first(til, ChannelStatus).decoded, '8100')
|
||||||
|
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
|
||||||
|
|
||||||
|
# SEND DATA -> bytes written to the socket, echo server sends them back
|
||||||
|
payload = b'Hello SCP81 relay - opaque TLS record bytes'
|
||||||
|
til = self.proact.handle_SendData(_send_data(payload))
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
# channel data length in the response = free Tx space, FF = ">255"
|
||||||
|
self.assertEqual(_first(til, ChannelDataLength).decoded, 255)
|
||||||
|
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
|
||||||
|
|
||||||
|
# RECEIVE DATA -> drain the bytes back to the "card". real card
|
||||||
|
# uses data-available event, we poll the buffer
|
||||||
|
# and may need several RECEIVE DATA commands, as the spec allows.
|
||||||
|
got = bytearray()
|
||||||
|
deadline = time.monotonic() + 3.0
|
||||||
|
while len(got) < len(payload) and time.monotonic() < deadline:
|
||||||
|
chan = self.proact.channels.channels[1]
|
||||||
|
chan.wait_rx(1.0)
|
||||||
|
til = self.proact.handle_ReceiveData(_receive_data(len(payload) - len(got)))
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281')
|
||||||
|
got += h2b(_first(til, ChannelData).decoded)
|
||||||
|
self.assertEqual(bytes(got), payload, "byte round-trip through the BIP relay")
|
||||||
|
|
||||||
|
# CLOSE CHANNEL -> socket closed, bookkeeping cleared
|
||||||
|
til = self.proact.handle_CloseChannel(_close_channel())
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
|
||||||
|
self.assertNotIn(1, self.proact.channels.channels)
|
||||||
|
|
||||||
|
def test_data_available_event_envelope(self):
|
||||||
|
# The empty->non-empty Rx transition raises ENVELOPE EVENT DOWNLOAD
|
||||||
|
self._open()
|
||||||
|
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
|
||||||
|
payload = b'PONG'
|
||||||
|
self.proact.handle_SendData(_send_data(payload))
|
||||||
|
chan = self.proact.channels.channels[1]
|
||||||
|
self.assertGreater(chan.wait_rx(2.0), 0)
|
||||||
|
# give the reader thread a beat to invoke the sink
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while not self.events and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.assertEqual(len(self.events), 1, "one data-available event on the empty->non-empty edge")
|
||||||
|
env = h2b(self.events[0])
|
||||||
|
# d6 0e | 99 01 09 (event: data available) | 82 02 82 81 terminal->UICC
|
||||||
|
# | b8 02 81 00 (channel 1 established) | b7 01 XX bytes available
|
||||||
|
self.assertEqual(b2h(env[:15]), 'd60e99010982028281b8028100b701')
|
||||||
|
self.assertGreaterEqual(env[15], 1)
|
||||||
|
self.assertLessEqual(env[15], len(payload))
|
||||||
|
|
||||||
|
def test_channel_number_from_device_identities(self):
|
||||||
|
# Two channels, not the old hardcoded 1
|
||||||
|
e2 = _EchoServer()
|
||||||
|
self.addCleanup(e2.close)
|
||||||
|
self.proact.handle_OpenChannel(_open_channel(self.echo.port))
|
||||||
|
# open a second channel with a second echo server
|
||||||
|
til2 = self.proact.handle_OpenChannel(_open_channel(e2.port))
|
||||||
|
self.assertEqual(sorted(self.proact.channels.channels), [1, 2])
|
||||||
|
self.assertEqual(_first(til2, ChannelStatus).decoded, '8200') # channel 2, established
|
||||||
|
|
||||||
|
# SEND DATA addressed to channel_2 must reach the second socket
|
||||||
|
self.assertTrue(e2.accepted.wait(timeout=2.0))
|
||||||
|
self.proact.handle_SendData(_send_data(b'two', chan='channel_2'))
|
||||||
|
chan2 = self.proact.channels.channels[2]
|
||||||
|
self.assertGreater(chan2.wait_rx(2.0), 0)
|
||||||
|
til = self.proact.handle_ReceiveData(_receive_data(3, chan='channel_2'))
|
||||||
|
self.assertEqual(h2b(_first(til, ChannelData).decoded), b'two')
|
||||||
|
# ..and nothing on chan 1
|
||||||
|
self.assertEqual(self.proact.channels.channels[1].available_rx(), 0)
|
||||||
|
|
||||||
|
def test_commands_on_closed_channel_report_bip_error(self):
|
||||||
|
# SEND/RECEIVE/CLOSE on a channel that was never opened must be rejected
|
||||||
|
# with a BIP error
|
||||||
|
for til in (self.proact.handle_SendData(_send_data(b'x', chan='channel_4')),
|
||||||
|
self.proact.handle_ReceiveData(_receive_data(1, chan='channel_4')),
|
||||||
|
self.proact.handle_CloseChannel(_close_channel(chan='channel_4'))):
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
res = _first(til, Result).decoded
|
||||||
|
self.assertEqual(res['general_result'], 'bearer_independent_protocol_error')
|
||||||
|
self.assertEqual(res['additional_information'], 'channel_id_not_valid')
|
||||||
|
|
||||||
|
def test_receive_more_than_available_is_missing_info(self):
|
||||||
|
# terminal must NOT wait if fewer than the requested bytes are buffered,
|
||||||
|
# eturns what it has with "performed with missing information".
|
||||||
|
self._open()
|
||||||
|
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
|
||||||
|
til = self.proact.handle_ReceiveData(_receive_data(10))
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
self.assertEqual(_first(til, Result).decoded['general_result'],
|
||||||
|
'performed_with_missing_information')
|
||||||
|
self.assertEqual(h2b(_first(til, ChannelData).decoded), b'')
|
||||||
|
self.assertEqual(_first(til, ChannelDataLength).decoded, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenChannelRefusalTest(unittest.TestCase):
|
||||||
|
"""Refusal is a TERMINAL RESPONSE, not an exception, raising takes the whole
|
||||||
|
proactive session down and leaves the card wondering why"""
|
||||||
|
|
||||||
|
ADDR = OtherAddress(decoded={'type_of_address': 'ipv4', 'address': bytes([127, 0, 0, 1])})
|
||||||
|
TCP = UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote', 'port_number': 1234})
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.proact = Proact()
|
||||||
|
self.addCleanup(self._close_all_channels)
|
||||||
|
|
||||||
|
def _close_all_channels(self):
|
||||||
|
for chan in list(self.proact.channels.channels.values()):
|
||||||
|
try:
|
||||||
|
chan.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _assert_refused(self, til, additional_information, chan_nr=0):
|
||||||
|
b''.join(x.to_tlv() for x in til) # must serialise, the transport posts it
|
||||||
|
res = _first(til, Result).decoded
|
||||||
|
self.assertEqual(res['general_result'], 'bearer_independent_protocol_error')
|
||||||
|
self.assertEqual(res['additional_information'], additional_information)
|
||||||
|
self.assertEqual(_first(til, ChannelStatus).decoded, '%02x00' % chan_nr) # 8.56
|
||||||
|
self.assertIsNotNone(_first(til, BearerDescription)) # 6.8.20
|
||||||
|
self.assertIsNotNone(_first(til, BufferSize)) # 6.8.21
|
||||||
|
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281') # 6.8.2
|
||||||
|
|
||||||
|
def test_transport_level(self):
|
||||||
|
cases = [[self.ADDR.to_tlv()]] # absent, 6.6.27.x Optional
|
||||||
|
for proto in ('udp_uicc_client_remote', 'tcp_uicc_server', 'udp_uicc_client_local',
|
||||||
|
'tcp_uicc_client_local', 'direct_channel'): # not TCP client remote
|
||||||
|
tl = UiccTransportLevel(decoded={'protocol_type': proto, 'port_number': 1234})
|
||||||
|
cases.append([tl.to_tlv(), self.ADDR.to_tlv()])
|
||||||
|
for extra in cases:
|
||||||
|
with self.subTest(extra=b2h(extra[0])):
|
||||||
|
self._assert_refused(self.proact.handle_OpenChannel(_open_channel_raw(extra)),
|
||||||
|
'requested_uicc_if_transp_level_not_available')
|
||||||
|
|
||||||
|
def test_destination_address(self):
|
||||||
|
v6 = OtherAddress(decoded={'type_of_address': 'ipv6', 'address': bytes(16)})
|
||||||
|
for extra in ([self.TCP.to_tlv()], # absent
|
||||||
|
[self.TCP.to_tlv(), v6.to_tlv()]): # not IPv4
|
||||||
|
with self.subTest(extra=len(extra)):
|
||||||
|
self._assert_refused(self.proact.handle_OpenChannel(_open_channel_raw(extra)),
|
||||||
|
'no_specific_cause')
|
||||||
|
|
||||||
|
def test_no_channel_left(self):
|
||||||
|
for _ in range(7): # 6.4.27.2, 6.4.27.3
|
||||||
|
self.proact.channels.channel_create()
|
||||||
|
cmd = _open_channel_raw([self.TCP.to_tlv(), self.ADDR.to_tlv()])
|
||||||
|
self._assert_refused(self.proact.handle_OpenChannel(cmd), 'no_channel_availabile')
|
||||||
|
|
||||||
|
def test_connect_failure(self):
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # port nothing listens on
|
||||||
|
s.bind(('127.0.0.1', 0))
|
||||||
|
dead_port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
til = self.proact.handle_OpenChannel(_open_channel(dead_port))
|
||||||
|
self._assert_refused(til, 'channel_closed', chan_nr=1) # 6.4.30
|
||||||
|
self.assertEqual(self.proact.channels.channels, {}) # channel given back
|
||||||
|
|
||||||
|
|
||||||
|
class ProvideLocalInformationTest(unittest.TestCase):
|
||||||
|
"""TS 102 223 6.8.7: only 00 gets a data object; the rest keeps the empty result."""
|
||||||
|
|
||||||
|
def _cmd(self, qualifier):
|
||||||
|
return _pcmd([
|
||||||
|
CommandDetails(decoded={'command_number': 1, 'type_of_command': 'provide_local_info',
|
||||||
|
'command_qualifier': qualifier}).to_tlv(),
|
||||||
|
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv()])
|
||||||
|
|
||||||
|
def test_location(self):
|
||||||
|
til = Proact().handle_ProvideLocalInformation(self._cmd(0x00))
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
|
||||||
|
self.assertEqual(b2h(_first(til, LocationInformation).to_tlv()), '930762f21000010001')
|
||||||
|
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281') # 6.8.2
|
||||||
|
|
||||||
|
def test_other_qualifiers_get_no_data_object(self):
|
||||||
|
for qualifier in (0x01, 0x03, 0x04, 0x1a):
|
||||||
|
with self.subTest(command_qualifier=qualifier):
|
||||||
|
til = Proact().handle_ProvideLocalInformation(self._cmd(qualifier))
|
||||||
|
b''.join(x.to_tlv() for x in til)
|
||||||
|
self.assertIsNone(_first(til, LocationInformation))
|
||||||
|
|
||||||
|
def test_location_is_configurable(self):
|
||||||
|
til = Proact(location=h2b('26f8100539')).handle_ProvideLocalInformation(self._cmd(0x00))
|
||||||
|
self.assertEqual(b2h(_first(til, LocationInformation).to_tlv()), '930526f8100539')
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalProfileTest(unittest.TestCase):
|
||||||
|
"""TS 102 223 5.2, one bit per CAT facility"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.profile = terminal_profile()
|
||||||
|
|
||||||
|
def byte(self, n):
|
||||||
|
return self.profile[n - 1] # 1-based, as 5.2 numbers them
|
||||||
|
|
||||||
|
def test_announced(self):
|
||||||
|
self.assertEqual(len(self.profile), 32)
|
||||||
|
self.assertEqual(self.byte(1), 0x13) # profile download, SMS-PP download b2+b5
|
||||||
|
self.assertEqual(self.byte(4), 0x02) # SEND SHORT MESSAGE
|
||||||
|
self.assertEqual(self.byte(5) & 0x01, 0x01) # SET UP EVENT LIST
|
||||||
|
self.assertEqual(self.byte(6), 0x0c) # events: data available, channel status
|
||||||
|
self.assertEqual(self.byte(12), 0x1f) # OPEN/CLOSE CHANNEL, RECEIVE/SEND DATA, STATUS
|
||||||
|
self.assertEqual(self.byte(13) >> 5, ProactChannels.MAX_CHANNELS)
|
||||||
|
self.assertEqual(self.byte(14), 0x60) # class ND, class NK
|
||||||
|
self.assertEqual(self.byte(17), 0x01) # TCP, UICC client mode, remote
|
||||||
|
|
||||||
|
def test_not_announced(self):
|
||||||
|
self.assertEqual(self.byte(3) & 0x60, 0) # POLL INTERVAL, POLLING OFF
|
||||||
|
self.assertEqual(self.byte(4) & 0xc0, 0) # PROVIDE LOCAL INFORMATION, NMR
|
||||||
|
self.assertEqual(self.byte(12) & 0xe0, 0) # SERVICE SEARCH/INFORMATION, DECLARE SERVICE
|
||||||
|
self.assertEqual(self.byte(14) & 0x1f, 0) # no characters down the display
|
||||||
|
for n in (7, 9, 10, 11, 15, 16, 18): # class "a", class "d", display, ESN/IMEISV
|
||||||
|
self.assertEqual(self.byte(n), 0)
|
||||||
|
|
||||||
|
def test_channel_count(self):
|
||||||
|
self.assertEqual(terminal_profile(3)[12] >> 5, 3)
|
||||||
|
with self.assertRaises(ValueError): # 8.56: 1 to 7
|
||||||
|
terminal_profile(8)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class BipSinkTest(unittest.TestCase):
|
||||||
|
"""Both sinks are optional, a driver with no SMS path at all must not crash and burn
|
||||||
|
with a card that sends one, and one that has one must get the PDU."""
|
||||||
|
|
||||||
|
def _submit(self):
|
||||||
|
return SMS_SUBMIT(tp_da=AddressField('12345', 'unknown', 'unknown'),
|
||||||
|
tp_ud=b'\x01\x02', tp_udl=2, tp_dcs=0xf6)
|
||||||
|
|
||||||
|
def test_sinks_default_to_none(self):
|
||||||
|
p = Proact()
|
||||||
|
self.assertIsNone(p.sms_sink)
|
||||||
|
|
||||||
|
def test_mo_sms_goes_to_the_sink(self):
|
||||||
|
seen = []
|
||||||
|
Proact(sms_sink=seen.append).send_sms_via_smpp(self._submit())
|
||||||
|
self.assertEqual(len(seen), 1)
|
||||||
|
|
||||||
|
def test_no_sms_sink_drops_instead_of_raising(self):
|
||||||
|
with self.assertLogs('pySim.bip', level='INFO'):
|
||||||
|
Proact().send_sms_via_smpp(self._submit())
|
||||||
@@ -20,7 +20,8 @@ class TestCardKeyProviderCsv(unittest.TestCase):
|
|||||||
"KIK3" : "00010204040506070809488B0C0D0E0F"}
|
"KIK3" : "00010204040506070809488B0C0D0E0F"}
|
||||||
|
|
||||||
csv_file_path = os.path.dirname(os.path.abspath(__file__)) + "/test_card_key_provider.csv"
|
csv_file_path = os.path.dirname(os.path.abspath(__file__)) + "/test_card_key_provider.csv"
|
||||||
card_key_provider_register(CardKeyProviderCsv(csv_file_path, column_keys))
|
card_key_field_cryptor = CardKeyFieldCryptor(column_keys)
|
||||||
|
card_key_provider_register(CardKeyProviderCsv(csv_file_path, card_key_field_cryptor))
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def test_card_key_provider_get(self):
|
def test_card_key_provider_get(self):
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the CAT (Card Application Toolkit) COMPREHENSION-TLV data objects"""
|
||||||
|
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Eric Wild <ewild@sysmocom.de>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# IEs not properly coverd by test_tlvs.py
|
||||||
|
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from osmocom.utils import b2h, h2b
|
||||||
|
|
||||||
|
from pySim.cat import IMEI, IMEISV, AccessTechnology, SupportedRadioAccessTechnologies
|
||||||
|
|
||||||
|
|
||||||
|
class IMEI_Test(unittest.TestCase):
|
||||||
|
"""TS 102 223 8.20: the IMEI IE is 8 bytes, coded as valie part of Mobile Identity IE from 124 008"""
|
||||||
|
|
||||||
|
IMEI_15 = '123456789012345'
|
||||||
|
ENCODED = '94081a32547698103254'
|
||||||
|
|
||||||
|
def test_encode_is_eight_bytes(self):
|
||||||
|
"""15 digits in 8 byte: 16 nibbles, one is type/parity framing."""
|
||||||
|
tlv = IMEI(decoded=self.IMEI_15).to_tlv()
|
||||||
|
self.assertEqual(b2h(tlv), self.ENCODED)
|
||||||
|
self.assertEqual(tlv[1], 0x08) # spec len 8
|
||||||
|
self.assertEqual(len(tlv) - 2, 8)
|
||||||
|
|
||||||
|
def test_first_octet_framing(self):
|
||||||
|
"""TS 24.008 table 10.5.4"""
|
||||||
|
octet1 = IMEI(decoded=self.IMEI_15).to_tlv()[2]
|
||||||
|
self.assertEqual(octet1 & 0x07, 2) # IMEI
|
||||||
|
self.assertEqual((octet1 >> 3) & 0x01, 1) # odd
|
||||||
|
self.assertEqual(octet1 >> 4, 1) # digit 1
|
||||||
|
|
||||||
|
def test_decodes_to_the_raw_imei(self):
|
||||||
|
"""strip framing nibble"""
|
||||||
|
ie = IMEI()
|
||||||
|
ie.from_tlv(h2b(self.ENCODED))
|
||||||
|
self.assertEqual(ie.decoded, self.IMEI_15)
|
||||||
|
|
||||||
|
def test_even_digit_count_uses_the_end_mark(self):
|
||||||
|
""""end marker, IMEISV case"""
|
||||||
|
ie = IMEI(decoded='1234567890123456')
|
||||||
|
tlv = ie.to_tlv()
|
||||||
|
self.assertEqual(tlv[2] >> 3 & 0x01, 0) # even
|
||||||
|
self.assertEqual(tlv[-1] >> 4, 0x0f) # end mark
|
||||||
|
back = IMEI()
|
||||||
|
back.from_tlv(tlv)
|
||||||
|
self.assertEqual(back.decoded, '1234567890123456')
|
||||||
|
|
||||||
|
|
||||||
|
class IMEISV_Test(unittest.TestCase):
|
||||||
|
"""TS 102 223 8.74, no fixed len, end marker"""
|
||||||
|
|
||||||
|
IMEISV_16 = '1234567890123456'
|
||||||
|
ENCODED = 'e2091332547698103254f6'
|
||||||
|
|
||||||
|
def test_encode(self):
|
||||||
|
self.assertEqual(b2h(IMEISV(decoded=self.IMEISV_16).to_tlv()), self.ENCODED)
|
||||||
|
|
||||||
|
def test_type_of_identity_and_end_mark(self):
|
||||||
|
value = IMEISV(decoded=self.IMEISV_16).to_tlv()[2:]
|
||||||
|
self.assertEqual(value[0] & 0x07, 3) # IMEISV
|
||||||
|
self.assertEqual((value[0] >> 3) & 0x01, 0) # even
|
||||||
|
self.assertEqual(value[-1] >> 4, 0x0f) # end mark
|
||||||
|
self.assertEqual(len(value), 9)
|
||||||
|
|
||||||
|
def test_decode(self):
|
||||||
|
ie = IMEISV()
|
||||||
|
ie.from_tlv(h2b(self.ENCODED))
|
||||||
|
self.assertEqual(ie.decoded, self.IMEISV_16)
|
||||||
|
|
||||||
|
|
||||||
|
class SupportedRadioAccessTechnologies_Test(unittest.TestCase):
|
||||||
|
"""TS 102 223 8.105"""
|
||||||
|
|
||||||
|
def test_encode_technology_enabled(self):
|
||||||
|
"""The flag used to have a bitmask of 0 so enabled -> 00 (that is disabled..)"""
|
||||||
|
ie = SupportedRadioAccessTechnologies(
|
||||||
|
decoded=[{'technology': 'eutran', 'state': {'enabled': True}}])
|
||||||
|
self.assertEqual(b2h(ie.to_tlv()), 'b4020801')
|
||||||
|
|
||||||
|
def test_encode_technology_disabled(self):
|
||||||
|
ie = SupportedRadioAccessTechnologies(
|
||||||
|
decoded=[{'technology': 'eutran', 'state': {'enabled': False}}])
|
||||||
|
self.assertEqual(b2h(ie.to_tlv()), 'b4020800')
|
||||||
|
|
||||||
|
def test_decode_technology(self):
|
||||||
|
"""old 0 bitmask = all enabled, no way to disable"""
|
||||||
|
for encoded, enabled in [('b4020800', False), ('b4020801', True)]:
|
||||||
|
with self.subTest(encoded=encoded):
|
||||||
|
ie = SupportedRadioAccessTechnologies()
|
||||||
|
ie.from_tlv(h2b(encoded))
|
||||||
|
self.assertEqual(ie.decoded[0]['technology'], 'eutran')
|
||||||
|
self.assertEqual(ie.decoded[0]['state']['enabled'], enabled)
|
||||||
|
|
||||||
|
def test_decode_technology_multiple(self):
|
||||||
|
ie = SupportedRadioAccessTechnologies()
|
||||||
|
ie.from_tlv(h2b('b40408010000'))
|
||||||
|
self.assertEqual([(e['technology'], e['state']['enabled']) for e in ie.decoded],
|
||||||
|
[('eutran', True), ('gsm', False)])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+640
@@ -0,0 +1,640 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# (C) 2025 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
|
||||||
|
#
|
||||||
|
# Author: Neels Hofmeyr
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from importlib import resources
|
||||||
|
from osmocom.utils import hexstr
|
||||||
|
from pySim.esim.saip import ProfileElementSequence
|
||||||
|
import pySim.esim.saip.personalization as p13n
|
||||||
|
import smdpp_data.upp
|
||||||
|
|
||||||
|
import xo
|
||||||
|
update_expected_output = False
|
||||||
|
|
||||||
|
def valstr(val):
|
||||||
|
if isinstance(val, io.BytesIO):
|
||||||
|
val = val.getvalue()
|
||||||
|
if isinstance(val, bytearray):
|
||||||
|
val = bytes(val)
|
||||||
|
return f'{val!r}'
|
||||||
|
|
||||||
|
def valtypestr(val):
|
||||||
|
if isinstance(val, dict):
|
||||||
|
types = []
|
||||||
|
for v in val.values():
|
||||||
|
types.append(f'{type(v).__name__}')
|
||||||
|
|
||||||
|
val_type = '{' + ', '.join(types) + '}'
|
||||||
|
else:
|
||||||
|
val_type = f'{type(val).__name__}'
|
||||||
|
return f'{valstr(val)}:{val_type}'
|
||||||
|
|
||||||
|
class ConfigurableParameterTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_parameters(self):
|
||||||
|
|
||||||
|
upp_fnames = (
|
||||||
|
'TS48v5_SAIP2.1A_NoBERTLV.der',
|
||||||
|
'TS48v5_SAIP2.3_BERTLV_SUCI.der',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Paramtest:
|
||||||
|
def __init__(self, param_cls, val, expect_val, expect_clean_val=None):
|
||||||
|
self.param_cls = param_cls
|
||||||
|
self.val = val
|
||||||
|
self.expect_clean_val = expect_clean_val
|
||||||
|
self.expect_val = expect_val
|
||||||
|
|
||||||
|
param_tests = [
|
||||||
|
Paramtest(param_cls=p13n.Imsi, val='123456',
|
||||||
|
expect_clean_val=str('123456'),
|
||||||
|
expect_val={'IMSI': hexstr('123456'),
|
||||||
|
'IMSI-ACC': '0040'}),
|
||||||
|
Paramtest(param_cls=p13n.Imsi, val=int(123456),
|
||||||
|
expect_val={'IMSI': hexstr('123456'),
|
||||||
|
'IMSI-ACC': '0040'}),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.Imsi, val='123456789012345',
|
||||||
|
expect_clean_val=str('123456789012345'),
|
||||||
|
expect_val={'IMSI': hexstr('123456789012345'),
|
||||||
|
'IMSI-ACC': '0020'}),
|
||||||
|
Paramtest(param_cls=p13n.Imsi, val=int(123456789012345),
|
||||||
|
expect_val={'IMSI': hexstr('123456789012345'),
|
||||||
|
'IMSI-ACC': '0020'}),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.Puk1,
|
||||||
|
val='12345678',
|
||||||
|
expect_clean_val=b'12345678',
|
||||||
|
expect_val='12345678'),
|
||||||
|
Paramtest(param_cls=p13n.Puk1,
|
||||||
|
val=int(12345678),
|
||||||
|
expect_clean_val=b'12345678',
|
||||||
|
expect_val='12345678'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.Puk2,
|
||||||
|
val='12345678',
|
||||||
|
expect_clean_val=b'12345678',
|
||||||
|
expect_val='12345678'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.Pin1,
|
||||||
|
val='1234',
|
||||||
|
expect_clean_val=b'1234\xff\xff\xff\xff',
|
||||||
|
expect_val='1234'),
|
||||||
|
Paramtest(param_cls=p13n.Pin1,
|
||||||
|
val='123456',
|
||||||
|
expect_clean_val=b'123456\xff\xff',
|
||||||
|
expect_val='123456'),
|
||||||
|
Paramtest(param_cls=p13n.Pin1,
|
||||||
|
val='12345678',
|
||||||
|
expect_clean_val=b'12345678',
|
||||||
|
expect_val='12345678'),
|
||||||
|
Paramtest(param_cls=p13n.Pin1,
|
||||||
|
val=int(1234),
|
||||||
|
expect_clean_val=b'1234\xff\xff\xff\xff',
|
||||||
|
expect_val='1234'),
|
||||||
|
Paramtest(param_cls=p13n.Pin1,
|
||||||
|
val=int(123456),
|
||||||
|
expect_clean_val=b'123456\xff\xff',
|
||||||
|
expect_val='123456'),
|
||||||
|
Paramtest(param_cls=p13n.Pin1,
|
||||||
|
val=int(12345678),
|
||||||
|
expect_clean_val=b'12345678',
|
||||||
|
expect_val='12345678'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.Adm1,
|
||||||
|
val='1234',
|
||||||
|
expect_clean_val=b'1234\xff\xff\xff\xff',
|
||||||
|
expect_val='1234'),
|
||||||
|
Paramtest(param_cls=p13n.Adm1,
|
||||||
|
val='123456',
|
||||||
|
expect_clean_val=b'123456\xff\xff',
|
||||||
|
expect_val='123456'),
|
||||||
|
Paramtest(param_cls=p13n.Adm1,
|
||||||
|
val='12345678',
|
||||||
|
expect_clean_val=b'12345678',
|
||||||
|
expect_val='12345678'),
|
||||||
|
Paramtest(param_cls=p13n.Adm1,
|
||||||
|
val=int(123456),
|
||||||
|
expect_clean_val=b'123456\xff\xff',
|
||||||
|
expect_val='123456'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.AlgorithmID,
|
||||||
|
val='Milenage',
|
||||||
|
expect_clean_val=1,
|
||||||
|
expect_val='Milenage'),
|
||||||
|
Paramtest(param_cls=p13n.AlgorithmID,
|
||||||
|
val='TUAK',
|
||||||
|
expect_clean_val=2,
|
||||||
|
expect_val='TUAK'),
|
||||||
|
Paramtest(param_cls=p13n.AlgorithmID,
|
||||||
|
val='usim-test',
|
||||||
|
expect_clean_val=3,
|
||||||
|
expect_val='usim_test'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.AlgorithmID,
|
||||||
|
val=1,
|
||||||
|
expect_clean_val=1,
|
||||||
|
expect_val='Milenage'),
|
||||||
|
Paramtest(param_cls=p13n.AlgorithmID,
|
||||||
|
val=2,
|
||||||
|
expect_clean_val=2,
|
||||||
|
expect_val='TUAK'),
|
||||||
|
Paramtest(param_cls=p13n.AlgorithmID,
|
||||||
|
val=3,
|
||||||
|
expect_clean_val=3,
|
||||||
|
expect_val='usim_test'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.K,
|
||||||
|
val='01020304050607080910111213141516',
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
Paramtest(param_cls=p13n.K,
|
||||||
|
val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
Paramtest(param_cls=p13n.K,
|
||||||
|
val=bytearray(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
Paramtest(param_cls=p13n.K,
|
||||||
|
val=io.BytesIO(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
Paramtest(param_cls=p13n.K,
|
||||||
|
val=int(11020304050607080910111213141516),
|
||||||
|
expect_clean_val=b'\x11\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='11020304050607080910111213141516'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.Opc,
|
||||||
|
val='01020304050607080910111213141516',
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
Paramtest(param_cls=p13n.Opc,
|
||||||
|
val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
Paramtest(param_cls=p13n.Opc,
|
||||||
|
val=bytearray(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
Paramtest(param_cls=p13n.Opc,
|
||||||
|
val=io.BytesIO(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
|
||||||
|
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
|
||||||
|
expect_val='01020304050607080910111213141516'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.SmspTpScAddr,
|
||||||
|
val='+1234567',
|
||||||
|
expect_clean_val=(True, '1234567'),
|
||||||
|
expect_val='+1234567'),
|
||||||
|
Paramtest(param_cls=p13n.SmspTpScAddr,
|
||||||
|
val=1234567,
|
||||||
|
expect_clean_val=(False, '1234567'),
|
||||||
|
expect_val='1234567'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.TuakNumberOfKeccak,
|
||||||
|
val='123',
|
||||||
|
expect_clean_val=123,
|
||||||
|
expect_val='123'),
|
||||||
|
Paramtest(param_cls=p13n.TuakNumberOfKeccak,
|
||||||
|
val=123,
|
||||||
|
expect_clean_val=123,
|
||||||
|
expect_val='123'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.MilenageRotationConstants,
|
||||||
|
val='0a 0b 0c 01 02',
|
||||||
|
expect_clean_val=b'\x0a\x0b\x0c\x01\x02',
|
||||||
|
expect_val='0a0b0c0102'),
|
||||||
|
Paramtest(param_cls=p13n.MilenageRotationConstants,
|
||||||
|
val=b'\x0a\x0b\x0c\x01\x02',
|
||||||
|
expect_clean_val=b'\x0a\x0b\x0c\x01\x02',
|
||||||
|
expect_val='0a0b0c0102'),
|
||||||
|
Paramtest(param_cls=p13n.MilenageRotationConstants,
|
||||||
|
val=bytearray(b'\x0a\x0b\x0c\x01\x02'),
|
||||||
|
expect_clean_val=b'\x0a\x0b\x0c\x01\x02',
|
||||||
|
expect_val='0a0b0c0102'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.MilenageXoringConstants,
|
||||||
|
val='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
' bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||||
|
' cccccccccccccccccccccccccccccccc'
|
||||||
|
' 11111111111111111111111111111111'
|
||||||
|
' 22222222222222222222222222222222',
|
||||||
|
expect_clean_val=b'\xaa' * 16
|
||||||
|
+ b'\xbb' * 16
|
||||||
|
+ b'\xcc' * 16
|
||||||
|
+ b'\x11' * 16
|
||||||
|
+ b'\x22' * 16,
|
||||||
|
expect_val='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||||
|
'cccccccccccccccccccccccccccccccc'
|
||||||
|
'11111111111111111111111111111111'
|
||||||
|
'22222222222222222222222222222222'),
|
||||||
|
Paramtest(param_cls=p13n.MilenageXoringConstants,
|
||||||
|
val=b'\xaa' * 16
|
||||||
|
+ b'\xbb' * 16
|
||||||
|
+ b'\xcc' * 16
|
||||||
|
+ b'\x11' * 16
|
||||||
|
+ b'\x22' * 16,
|
||||||
|
expect_clean_val=b'\xaa' * 16
|
||||||
|
+ b'\xbb' * 16
|
||||||
|
+ b'\xcc' * 16
|
||||||
|
+ b'\x11' * 16
|
||||||
|
+ b'\x22' * 16,
|
||||||
|
expect_val='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||||
|
'cccccccccccccccccccccccccccccccc'
|
||||||
|
'11111111111111111111111111111111'
|
||||||
|
'22222222222222222222222222222222'),
|
||||||
|
|
||||||
|
Paramtest(param_cls=p13n.MncLen,
|
||||||
|
val='2',
|
||||||
|
expect_clean_val=2,
|
||||||
|
expect_val='2'),
|
||||||
|
Paramtest(param_cls=p13n.MncLen,
|
||||||
|
val=3,
|
||||||
|
expect_clean_val=3,
|
||||||
|
expect_val='3'),
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
for sdkey_cls in (
|
||||||
|
# thin out the number of tests, as a compromise between completeness and test runtime
|
||||||
|
p13n.SdKeyScp02Kvn20AesDek,
|
||||||
|
#p13n.SdKeyScp02Kvn20AesEnc,
|
||||||
|
#p13n.SdKeyScp02Kvn20AesMac,
|
||||||
|
#p13n.SdKeyScp02Kvn21AesDek,
|
||||||
|
p13n.SdKeyScp02Kvn21AesEnc,
|
||||||
|
#p13n.SdKeyScp02Kvn21AesMac,
|
||||||
|
#p13n.SdKeyScp02Kvn22AesDek,
|
||||||
|
#p13n.SdKeyScp02Kvn22AesEnc,
|
||||||
|
p13n.SdKeyScp02Kvn22AesMac,
|
||||||
|
#p13n.SdKeyScp02KvnffAesDek,
|
||||||
|
#p13n.SdKeyScp02KvnffAesEnc,
|
||||||
|
#p13n.SdKeyScp02KvnffAesMac,
|
||||||
|
p13n.SdKeyScp03Kvn30AesDek,
|
||||||
|
#p13n.SdKeyScp03Kvn30AesEnc,
|
||||||
|
#p13n.SdKeyScp03Kvn30AesMac,
|
||||||
|
#p13n.SdKeyScp03Kvn31AesDek,
|
||||||
|
p13n.SdKeyScp03Kvn31AesEnc,
|
||||||
|
#p13n.SdKeyScp03Kvn31AesMac,
|
||||||
|
#p13n.SdKeyScp03Kvn32AesDek,
|
||||||
|
#p13n.SdKeyScp03Kvn32AesEnc,
|
||||||
|
p13n.SdKeyScp03Kvn32AesMac,
|
||||||
|
#p13n.SdKeyScp80Kvn01AesDek,
|
||||||
|
#p13n.SdKeyScp80Kvn01AesEnc,
|
||||||
|
#p13n.SdKeyScp80Kvn01AesMac,
|
||||||
|
p13n.SdKeyScp80Kvn01DesDek,
|
||||||
|
#p13n.SdKeyScp80Kvn01DesEnc,
|
||||||
|
#p13n.SdKeyScp80Kvn01DesMac,
|
||||||
|
#p13n.SdKeyScp80Kvn02AesDek,
|
||||||
|
p13n.SdKeyScp80Kvn02AesEnc,
|
||||||
|
#p13n.SdKeyScp80Kvn02AesMac,
|
||||||
|
#p13n.SdKeyScp80Kvn02DesDek,
|
||||||
|
#p13n.SdKeyScp80Kvn02DesEnc,
|
||||||
|
p13n.SdKeyScp80Kvn02DesMac,
|
||||||
|
#p13n.SdKeyScp80Kvn03AesDek,
|
||||||
|
#p13n.SdKeyScp80Kvn03AesEnc,
|
||||||
|
#p13n.SdKeyScp80Kvn03AesMac,
|
||||||
|
p13n.SdKeyScp80Kvn03DesDek,
|
||||||
|
#p13n.SdKeyScp80Kvn03DesEnc,
|
||||||
|
#p13n.SdKeyScp80Kvn03DesMac,
|
||||||
|
p13n.SdKeyScp81Kvn40AesDek,
|
||||||
|
#p13n.SdKeyScp81Kvn40Tlspsk,
|
||||||
|
#p13n.SdKeyScp81Kvn41AesDek,
|
||||||
|
p13n.SdKeyScp81Kvn41Tlspsk,
|
||||||
|
#p13n.SdKeyScp81Kvn42AesDek,
|
||||||
|
#p13n.SdKeyScp81Kvn42Tlspsk,
|
||||||
|
):
|
||||||
|
|
||||||
|
for key_len in sdkey_cls.allow_len:
|
||||||
|
val = '0102030405060708091011121314151617181920212223242526272829303132'
|
||||||
|
expect_clean_val = (b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'
|
||||||
|
b'\x17\x18\x19\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x30\x31\x32')
|
||||||
|
expect_val = '0102030405060708091011121314151617181920212223242526272829303132'
|
||||||
|
|
||||||
|
val = val[:key_len*2]
|
||||||
|
expect_clean_val = expect_clean_val[:key_len]
|
||||||
|
expect_val = val
|
||||||
|
|
||||||
|
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
|
||||||
|
|
||||||
|
# test bytes input
|
||||||
|
val = expect_clean_val
|
||||||
|
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
|
||||||
|
|
||||||
|
# test bytearray input
|
||||||
|
val = bytearray(expect_clean_val)
|
||||||
|
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
|
||||||
|
|
||||||
|
# test BytesIO input
|
||||||
|
val = io.BytesIO(expect_clean_val)
|
||||||
|
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
|
||||||
|
|
||||||
|
if key_len == 16:
|
||||||
|
# test huge integer input.
|
||||||
|
# needs to start with nonzero.. stupid
|
||||||
|
val = 11020304050607080910111213141516
|
||||||
|
expect_clean_val = (b'\x11\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16')
|
||||||
|
expect_val = '11020304050607080910111213141516'
|
||||||
|
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
for upp_fname in upp_fnames:
|
||||||
|
test_idx = -1
|
||||||
|
try:
|
||||||
|
|
||||||
|
der = resources.read_binary(smdpp_data.upp, upp_fname)
|
||||||
|
|
||||||
|
for t in param_tests:
|
||||||
|
test_idx += 1
|
||||||
|
logloc = f'{upp_fname} {t.param_cls.__name__}(val={valtypestr(t.val)})'
|
||||||
|
|
||||||
|
param = None
|
||||||
|
try:
|
||||||
|
param = t.param_cls()
|
||||||
|
param.input_value = t.val
|
||||||
|
param.validate()
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f'{logloc}: {e}') from e
|
||||||
|
|
||||||
|
clean_val = param.value
|
||||||
|
logloc = f'{logloc} clean_val={valtypestr(clean_val)}'
|
||||||
|
if t.expect_clean_val is not None and t.expect_clean_val != clean_val:
|
||||||
|
raise ValueError(f'{logloc}: expected'
|
||||||
|
f' expect_clean_val={valtypestr(t.expect_clean_val)}')
|
||||||
|
|
||||||
|
# on my laptop, deepcopy is about 30% slower than decoding the DER from scratch:
|
||||||
|
# pes = copy.deepcopy(orig_pes)
|
||||||
|
pes = ProfileElementSequence.from_der(der)
|
||||||
|
try:
|
||||||
|
param.apply(pes)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f'{logloc} apply_val(clean_val): {e}') from e
|
||||||
|
|
||||||
|
changed_der = pes.to_der()
|
||||||
|
|
||||||
|
pes2 = ProfileElementSequence.from_der(changed_der)
|
||||||
|
|
||||||
|
read_back_val = t.param_cls.get_value_from_pes(pes2)
|
||||||
|
|
||||||
|
# compose log string to show the precise type of dict values
|
||||||
|
if isinstance(read_back_val, dict):
|
||||||
|
types = set()
|
||||||
|
for v in read_back_val.values():
|
||||||
|
types.add(f'{type(v).__name__}')
|
||||||
|
|
||||||
|
read_back_val_type = '{' + ', '.join(types) + '}'
|
||||||
|
else:
|
||||||
|
read_back_val_type = f'{type(read_back_val).__name__}'
|
||||||
|
|
||||||
|
logloc = (f'{logloc} read_back_val={valtypestr(read_back_val)}')
|
||||||
|
|
||||||
|
if isinstance(read_back_val, dict) and not t.param_cls.get_name() in read_back_val.keys():
|
||||||
|
raise ValueError(f'{logloc}: expected to find name {t.param_cls.get_name()!r} in read_back_val')
|
||||||
|
|
||||||
|
expect_val = t.expect_val
|
||||||
|
if not isinstance(expect_val, dict):
|
||||||
|
expect_val = { t.param_cls.get_name(): expect_val }
|
||||||
|
if read_back_val != expect_val:
|
||||||
|
raise ValueError(f'{logloc}: expected {expect_val=!r}:{type(t.expect_val).__name__}')
|
||||||
|
|
||||||
|
ok = logloc.replace(' clean_val', '\n\tclean_val'
|
||||||
|
).replace(' read_back_val', '\n\tread_back_val'
|
||||||
|
).replace('=', '=\t'
|
||||||
|
)
|
||||||
|
output = f'\nok: {ok}'
|
||||||
|
outputs.append(output)
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f'Error while testing UPP {upp_fname} {test_idx=}: {e}') from e
|
||||||
|
|
||||||
|
output = '\n'.join(outputs) + '\n'
|
||||||
|
xo_name = 'test_configurable_parameters'
|
||||||
|
if update_expected_output:
|
||||||
|
with resources.path(xo, xo_name) as xo_path:
|
||||||
|
with open(xo_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(output)
|
||||||
|
else:
|
||||||
|
xo_str = resources.read_text(xo, xo_name)
|
||||||
|
if xo_str != output:
|
||||||
|
at = 0
|
||||||
|
while at < len(output):
|
||||||
|
if output[at] == xo_str[at]:
|
||||||
|
at += 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
raise RuntimeError(f'output differs from expected output at position {at}: "{output[at:at+20]}" != "{xo_str[at:at+20]}"')
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateVal(unittest.TestCase):
|
||||||
|
"""validate_val() tests for various ConfigurableParameter subclasses."""
|
||||||
|
|
||||||
|
def _ok(self, cls, val, expected=None):
|
||||||
|
result = cls.validate_val(val)
|
||||||
|
if expected is not None:
|
||||||
|
self.assertEqual(result, expected)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _err(self, cls, val):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
cls.validate_val(val)
|
||||||
|
|
||||||
|
# --- Iccid ---
|
||||||
|
|
||||||
|
def test_iccid_18digits_adds_luhn(self):
|
||||||
|
result = self._ok(p13n.Iccid, '998877665544332211')
|
||||||
|
self.assertIsInstance(result, str)
|
||||||
|
self.assertEqual(len(result), 19)
|
||||||
|
self.assertTrue(result.isdecimal())
|
||||||
|
|
||||||
|
def test_iccid_19digits_passthrough(self):
|
||||||
|
result = self._ok(p13n.Iccid, '9988776655443322110')
|
||||||
|
self.assertIsInstance(result, str)
|
||||||
|
self.assertEqual(len(result), 19)
|
||||||
|
|
||||||
|
def test_iccid_too_short(self):
|
||||||
|
self._err(p13n.Iccid, '12345678901234567') # 17 digits
|
||||||
|
|
||||||
|
def test_iccid_too_long(self):
|
||||||
|
self._err(p13n.Iccid, '1' * 21)
|
||||||
|
|
||||||
|
def test_iccid_non_digits(self):
|
||||||
|
self._err(p13n.Iccid, '99887766554433221X')
|
||||||
|
|
||||||
|
# --- Imsi ---
|
||||||
|
|
||||||
|
def test_imsi_valid_short(self):
|
||||||
|
self._ok(p13n.Imsi, '001010', '001010')
|
||||||
|
|
||||||
|
def test_imsi_valid_long(self):
|
||||||
|
self._ok(p13n.Imsi, '001010123456789', '001010123456789')
|
||||||
|
|
||||||
|
def test_imsi_too_short(self):
|
||||||
|
self._err(p13n.Imsi, '12345') # 5 digits, min is 6
|
||||||
|
|
||||||
|
def test_imsi_too_long(self):
|
||||||
|
self._err(p13n.Imsi, '1' * 16)
|
||||||
|
|
||||||
|
def test_imsi_non_digits(self):
|
||||||
|
self._err(p13n.Imsi, '00101A123456789')
|
||||||
|
|
||||||
|
# --- Pin1 ---
|
||||||
|
|
||||||
|
def test_pin1_4digits(self):
|
||||||
|
# DecimalHexParam encodes each digit as its ASCII byte, then rpad to 8 bytes with 0xff
|
||||||
|
self._ok(p13n.Pin1, '1234', b'1234\xff\xff\xff\xff')
|
||||||
|
|
||||||
|
def test_pin1_8digits(self):
|
||||||
|
self._ok(p13n.Pin1, '12345678', b'12345678')
|
||||||
|
|
||||||
|
def test_pin1_too_short(self):
|
||||||
|
self._err(p13n.Pin1, '123')
|
||||||
|
|
||||||
|
def test_pin1_too_long(self):
|
||||||
|
self._err(p13n.Pin1, '123456789')
|
||||||
|
|
||||||
|
def test_pin1_non_digits(self):
|
||||||
|
self._err(p13n.Pin1, '123A')
|
||||||
|
|
||||||
|
# --- Puk1 ---
|
||||||
|
|
||||||
|
def test_puk1_8digits(self):
|
||||||
|
self._ok(p13n.Puk1, '12345678', b'12345678')
|
||||||
|
|
||||||
|
def test_puk1_wrong_length(self):
|
||||||
|
self._err(p13n.Puk1, '1234567') # 7 digits
|
||||||
|
self._err(p13n.Puk1, '123456789') # 9 digits
|
||||||
|
|
||||||
|
def test_puk1_non_digits(self):
|
||||||
|
self._err(p13n.Puk1, '1234567X')
|
||||||
|
|
||||||
|
# --- K (BinaryParam) ---
|
||||||
|
|
||||||
|
def test_k_valid_hex_str(self):
|
||||||
|
self._ok(p13n.K, '000102030405060708090a0b0c0d0e0f',
|
||||||
|
b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f')
|
||||||
|
|
||||||
|
def test_k_valid_bytes(self):
|
||||||
|
raw = bytes(range(16))
|
||||||
|
self._ok(p13n.K, raw, raw)
|
||||||
|
|
||||||
|
def test_k_wrong_length(self):
|
||||||
|
self._err(p13n.K, '00' * 15) # 15 bytes, allow_len requires 16 or 32
|
||||||
|
|
||||||
|
def test_k_non_hex(self):
|
||||||
|
self._err(p13n.K, 'gg' * 16)
|
||||||
|
|
||||||
|
def test_k_odd_hex_digits(self):
|
||||||
|
self._err(p13n.K, '0' * 31) # odd number of hex digits
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnumParam(unittest.TestCase):
|
||||||
|
"""Tests for the EnumParam machinery, using AlgorithmID as the concrete subclass."""
|
||||||
|
|
||||||
|
# --- validate_val ---
|
||||||
|
|
||||||
|
def test_validate_by_name_exact(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val('Milenage'), 1)
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val('TUAK'), 2)
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val('usim_test'), 3)
|
||||||
|
|
||||||
|
def test_validate_by_int(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val(1), 1)
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val(2), 2)
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val(3), 3)
|
||||||
|
|
||||||
|
def test_validate_fuzzy_case(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val('milenage'), 1)
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val('MILENAGE'), 1)
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val('tuak'), 2)
|
||||||
|
|
||||||
|
def test_validate_fuzzy_hyphen_underscore(self):
|
||||||
|
# 'usim-test' has a hyphen; enum member is 'usim_test' — must fuzzy-match
|
||||||
|
self.assertEqual(p13n.AlgorithmID.validate_val('usim-test'), 3)
|
||||||
|
|
||||||
|
def test_validate_invalid_name(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
p13n.AlgorithmID.validate_val('unknown')
|
||||||
|
|
||||||
|
def test_validate_invalid_int(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
p13n.AlgorithmID.validate_val(99)
|
||||||
|
|
||||||
|
def test_validate_returns_int(self):
|
||||||
|
result = p13n.AlgorithmID.validate_val('Milenage')
|
||||||
|
self.assertIsInstance(result, int)
|
||||||
|
self.assertNotIsInstance(result, enum.Enum)
|
||||||
|
|
||||||
|
# --- map_name_to_val ---
|
||||||
|
|
||||||
|
def test_map_name_exact(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.map_name_to_val('Milenage'), 1)
|
||||||
|
|
||||||
|
def test_map_name_fuzzy(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.map_name_to_val('milenage'), 1)
|
||||||
|
self.assertEqual(p13n.AlgorithmID.map_name_to_val('usim-test'), 3)
|
||||||
|
|
||||||
|
def test_map_name_strict_raises(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
p13n.AlgorithmID.map_name_to_val('unknown', strict=True)
|
||||||
|
|
||||||
|
def test_map_name_nonstrict_returns_none(self):
|
||||||
|
self.assertIsNone(p13n.AlgorithmID.map_name_to_val('unknown', strict=False))
|
||||||
|
|
||||||
|
# --- map_val_to_name ---
|
||||||
|
|
||||||
|
def test_map_val_known(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.map_val_to_name(1), 'Milenage')
|
||||||
|
self.assertEqual(p13n.AlgorithmID.map_val_to_name(2), 'TUAK')
|
||||||
|
self.assertEqual(p13n.AlgorithmID.map_val_to_name(3), 'usim_test')
|
||||||
|
|
||||||
|
def test_map_val_unknown_nonstrict(self):
|
||||||
|
self.assertIsNone(p13n.AlgorithmID.map_val_to_name(99))
|
||||||
|
|
||||||
|
def test_map_val_unknown_strict(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
p13n.AlgorithmID.map_val_to_name(99, strict=True)
|
||||||
|
|
||||||
|
# --- name_normalize ---
|
||||||
|
|
||||||
|
def test_name_normalize(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.name_normalize('Milenage'), 'Milenage')
|
||||||
|
self.assertEqual(p13n.AlgorithmID.name_normalize('milenage'), 'Milenage')
|
||||||
|
self.assertEqual(p13n.AlgorithmID.name_normalize('usim-test'), 'usim_test')
|
||||||
|
|
||||||
|
# --- clean_name_str ---
|
||||||
|
|
||||||
|
def test_clean_name_str(self):
|
||||||
|
self.assertEqual(p13n.AlgorithmID.clean_name_str('usim-test'), 'usimtest')
|
||||||
|
self.assertEqual(p13n.AlgorithmID.clean_name_str('usim_test'), 'usimtest')
|
||||||
|
self.assertEqual(p13n.AlgorithmID.clean_name_str('Milenage'), 'milenage')
|
||||||
|
self.assertEqual(p13n.AlgorithmID.clean_name_str('foo bar!'), 'foobar')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if '-u' in sys.argv:
|
||||||
|
update_expected_output = True
|
||||||
|
sys.argv.remove('-u')
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# (C) 2026 by sysmocom - s.f.m.c. GmbH
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# Author: Philipp Maier <pmaier@sysmocom.de>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
from pySim.profile import CardProfile
|
||||||
|
from pySim.ts_51_011 import CardProfileSIM
|
||||||
|
from pySim.ts_102_221 import CardProfileUICC
|
||||||
|
|
||||||
|
class TestDecodeSelectResponse_CardProfile(unittest.TestCase):
|
||||||
|
|
||||||
|
def decode_select_response(self, card_Profile: CardProfile, testcases: list[dict]):
|
||||||
|
for testcase in testcases:
|
||||||
|
resp_hex = testcase['resp_hex']
|
||||||
|
decoded = card_Profile.decode_select_response(resp_hex)
|
||||||
|
if testcase['decoded']:
|
||||||
|
self.assertEqual(decoded, testcase['decoded'])
|
||||||
|
else:
|
||||||
|
print("no testvector to compare against, assuming the following output is correct:")
|
||||||
|
print("resp_hex:", resp_hex)
|
||||||
|
print("decoded:", decoded)
|
||||||
|
|
||||||
|
def test_CardProfileSIM(self):
|
||||||
|
testcases = [
|
||||||
|
# MF
|
||||||
|
{"resp_hex" : "000000003f000100000000000981020c0400838a838a",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'mf'}}, 'proprietary_info': {'available_memory': 0}, 'file_id': '3f00', 'file_characteristics': '81', 'num_direct_child_df': 2, 'num_direct_child_ef': 12, 'num_chv_unblock_adm_codes': 4}},
|
||||||
|
# DF.TELECOM
|
||||||
|
{"resp_hex" : "000000007f100200000000000981000d0400838a838a",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'df'}}, 'proprietary_info': {'available_memory': 0}, 'file_id': '7f10', 'file_characteristics': '81', 'num_direct_child_df': 0, 'num_direct_child_ef': 13, 'num_chv_unblock_adm_codes': 4}},
|
||||||
|
# EF.MSISDN
|
||||||
|
{"resp_hex" : "000000346f40040011ffff0102011a",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'working_ef', 'structure': 'linear_fixed'}, 'record_len': 26, 'num_of_rec': 2}, 'proprietary_info': {}, 'file_id': '6f40', 'file_size': 52, 'access_conditions': '11ffff', 'life_cycle_status_int': 'creation'}},
|
||||||
|
# EF.ICCID
|
||||||
|
{"resp_hex" : "0000000a2fe204000cffff01020000",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'working_ef', 'structure': 'transparent'}}, 'proprietary_info': {}, 'file_id': '2fe2', 'file_size': 10, 'access_conditions': '0cffff', 'life_cycle_status_int': 'creation'}},
|
||||||
|
]
|
||||||
|
self.decode_select_response(CardProfileSIM, testcases)
|
||||||
|
|
||||||
|
def test_CardProfileUICC(self):
|
||||||
|
testcases = [
|
||||||
|
# MF
|
||||||
|
{"resp_hex" : "622c8202782183023f00a50c80017183040003a7388701018a01058b032f0601c60c90016083010183010a83010b",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'df', 'structure': 'no_info_given'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'?\x00', 'proprietary_information': {'uicc_characteristics': b'q', 'available_memory': 239416, 'supported_filesystem_commands': {'terminal_capability': True}}, 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'/\x06', 'ef_arr_record_nr': 1}, 'pin_status_template_do': [{'ps_do': b'`'}, {'key_reference': 1}, {'key_reference': 10}, {'key_reference': 11}]}},
|
||||||
|
# ADF.USIM
|
||||||
|
{"resp_hex" : "623d8202782183027fd0840ca0000000871002ff49ff0589a50c80017183040003a7388701018a01058b032f0601c60f90017083010183018183010a83010b",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'df', 'structure': 'no_info_given'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'\x7f\xd0', 'df_name': b'\xa0\x00\x00\x00\x87\x10\x02\xffI\xff\x05\x89', 'proprietary_information': {'uicc_characteristics': b'q', 'available_memory': 239416, 'supported_filesystem_commands': {'terminal_capability': True}}, 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'/\x06', 'ef_arr_record_nr': 1}, 'pin_status_template_do': [{'ps_do': b'p'}, {'key_reference': 1}, {'key_reference': 129}, {'key_reference': 10}, {'key_reference': 11}]}},
|
||||||
|
# ADF.ISIM
|
||||||
|
{"resp_hex" : "623d8202782183027fb0840ca0000000871004ff49ff0589a50c80017183040003a7388701018a01058b032f0601c60f90017083010183018183010a83010b",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'df', 'structure': 'no_info_given'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'\x7f\xb0', 'df_name': b'\xa0\x00\x00\x00\x87\x10\x04\xffI\xff\x05\x89', 'proprietary_information': {'uicc_characteristics': b'q', 'available_memory': 239416, 'supported_filesystem_commands': {'terminal_capability': True}}, 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'/\x06', 'ef_arr_record_nr': 1}, 'pin_status_template_do': [{'ps_do': b'p'}, {'key_reference': 1}, {'key_reference': 129}, {'key_reference': 10}, {'key_reference': 11}]}},
|
||||||
|
# EF.IMSI
|
||||||
|
{"resp_hex" : "62178202412183026f078a01058b036f060a80020009880138",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'working_ef', 'structure': 'transparent'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'o\x07', 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'o\x06', 'ef_arr_record_nr': 10}, 'file_size': 9, 'short_file_identifier': 7}},
|
||||||
|
# EF.ECC
|
||||||
|
{"resp_hex" : "621a82054221000e0283026fb78a01058b036f06088002001c880108",
|
||||||
|
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'working_ef', 'structure': 'linear_fixed'}, 'record_len': 14, 'num_of_rec': 2}, 'file_identifier': b'o\xb7', 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'o\x06', 'ef_arr_record_nr': 8}, 'file_size': 28, 'short_file_identifier': 1}},
|
||||||
|
]
|
||||||
|
self.decode_select_response(CardProfileUICC, testcases)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -21,7 +21,7 @@ import copy
|
|||||||
from osmocom.utils import h2b, b2h
|
from osmocom.utils import h2b, b2h
|
||||||
|
|
||||||
from pySim.esim.saip import *
|
from pySim.esim.saip import *
|
||||||
from pySim.esim.saip.personalization import *
|
from pySim.esim.saip import personalization
|
||||||
from pprint import pprint as pp
|
from pprint import pprint as pp
|
||||||
|
|
||||||
|
|
||||||
@@ -55,14 +55,56 @@ class SaipTest(unittest.TestCase):
|
|||||||
def test_personalization(self):
|
def test_personalization(self):
|
||||||
"""Test some of the personalization operations."""
|
"""Test some of the personalization operations."""
|
||||||
pes = copy.deepcopy(self.pes)
|
pes = copy.deepcopy(self.pes)
|
||||||
params = [Puk1('01234567'), Puk2(98765432), Pin1('1111'), Pin2(2222), Adm1('11111111'),
|
params = [personalization.Puk1('01234567'),
|
||||||
K(h2b('000102030405060708090a0b0c0d0e0f')), Opc(h2b('101112131415161718191a1b1c1d1e1f'))]
|
personalization.Puk2(98765432),
|
||||||
|
personalization.Pin1('1111'),
|
||||||
|
personalization.Pin2(2222),
|
||||||
|
personalization.Adm1('11111111'),
|
||||||
|
personalization.K(h2b('000102030405060708090a0b0c0d0e0f')),
|
||||||
|
personalization.Opc(h2b('101112131415161718191a1b1c1d1e1f'))]
|
||||||
for p in params:
|
for p in params:
|
||||||
p.validate()
|
p.validate()
|
||||||
p.apply(pes)
|
p.apply(pes)
|
||||||
# TODO: we don't actually test the results here, but we just verify there is no exception
|
# TODO: we don't actually test the results here, but we just verify there is no exception
|
||||||
pes.to_der()
|
pes.to_der()
|
||||||
|
|
||||||
|
def test_personalization2(self):
|
||||||
|
"""Test some of the personalization operations."""
|
||||||
|
cls = personalization.SdKeyScp80Kvn01DesEnc
|
||||||
|
pes = ProfileElementSequence.from_der(self.per_input)
|
||||||
|
prev_val = tuple(cls.get_values_from_pes(pes))
|
||||||
|
print(f'{prev_val=}')
|
||||||
|
self.assertTrue(prev_val)
|
||||||
|
|
||||||
|
set_val = '42342342342342342342342342342342'
|
||||||
|
param = cls(set_val)
|
||||||
|
param.validate()
|
||||||
|
param.apply(pes)
|
||||||
|
|
||||||
|
get_val1 = tuple(cls.get_values_from_pes(pes))
|
||||||
|
print(f'{get_val1=} {set_val=}')
|
||||||
|
self.assertEqual(get_val1, ({cls.name: set_val},))
|
||||||
|
|
||||||
|
get_val1b = tuple(cls.get_values_from_pes(pes))
|
||||||
|
print(f'{get_val1b=} {set_val=}')
|
||||||
|
self.assertEqual(get_val1b, ({cls.name: set_val},))
|
||||||
|
|
||||||
|
der = pes.to_der()
|
||||||
|
|
||||||
|
get_val1c = tuple(cls.get_values_from_pes(pes))
|
||||||
|
print(f'{get_val1c=} {set_val=}')
|
||||||
|
self.assertEqual(get_val1c, ({cls.name: set_val},))
|
||||||
|
|
||||||
|
# assertTrue to not dump the entire der.
|
||||||
|
# Expecting the modified DER to be different. If this assertion fails, then no change has happened in the output
|
||||||
|
# DER and the ConfigurableParameter subclass is buggy.
|
||||||
|
self.assertTrue(der != self.per_input)
|
||||||
|
|
||||||
|
pes2 = ProfileElementSequence.from_der(der)
|
||||||
|
get_val2 = tuple(cls.get_values_from_pes(pes2))
|
||||||
|
print(f'{get_val2=} {set_val=}')
|
||||||
|
self.assertEqual(get_val2, ({cls.name: set_val},))
|
||||||
|
|
||||||
def test_constructor_encode(self):
|
def test_constructor_encode(self):
|
||||||
"""Test that DER-encoding of PE created by "empty" constructor works without raising exception."""
|
"""Test that DER-encoding of PE created by "empty" constructor works without raising exception."""
|
||||||
for cls in [ProfileElementMF, ProfileElementPuk, ProfileElementPin, ProfileElementTelecom,
|
for cls in [ProfileElementMF, ProfileElementPuk, ProfileElementPin, ProfileElementTelecom,
|
||||||
@@ -90,5 +132,34 @@ class OidTest(unittest.TestCase):
|
|||||||
self.assertTrue(oid.OID('1.0.1') > oid.OID('1.0'))
|
self.assertTrue(oid.OID('1.0.1') > oid.OID('1.0'))
|
||||||
self.assertTrue(oid.OID('1.0.2') > oid.OID('1.0.1'))
|
self.assertTrue(oid.OID('1.0.2') > oid.OID('1.0.1'))
|
||||||
|
|
||||||
|
class NonMatchTest(unittest.TestCase):
|
||||||
|
def test_nonmatch(self):
|
||||||
|
# non-matches before, in between and after matches
|
||||||
|
match_list = [Match(a=10, b=10, size=5), Match(a=20, b=20, size=4)]
|
||||||
|
nm_list = NonMatch.from_matchlist(match_list, 26)
|
||||||
|
self.assertEqual(nm_list, [NonMatch(a=0, b=0, size=10), NonMatch(a=15, b=15, size=5),
|
||||||
|
NonMatch(a=24, b=24, size=2)])
|
||||||
|
|
||||||
|
def test_nonmatch_beg(self):
|
||||||
|
# single match at beginning
|
||||||
|
match_list = [Match(a=0, b=0, size=5)]
|
||||||
|
nm_list = NonMatch.from_matchlist(match_list, 20)
|
||||||
|
self.assertEqual(nm_list, [NonMatch(a=5, b=5, size=15)])
|
||||||
|
|
||||||
|
def test_nonmatch_end(self):
|
||||||
|
# single match at end
|
||||||
|
match_list = [Match(a=19, b=19, size=5)]
|
||||||
|
nm_list = NonMatch.from_matchlist(match_list, 24)
|
||||||
|
self.assertEqual(nm_list, [NonMatch(a=0, b=0, size=19)])
|
||||||
|
|
||||||
|
def test_nonmatch_none(self):
|
||||||
|
# no match at all
|
||||||
|
match_list = []
|
||||||
|
nm_list = NonMatch.from_matchlist(match_list, 24)
|
||||||
|
self.assertEqual(nm_list, [NonMatch(a=0, b=0, size=24)])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user