forked from public/pysim
Compare commits
201 Commits
laforge/ot
...
pmaier/rcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ab73f74c | ||
|
|
47aa0a7fb2 | ||
|
|
b851a88533 | ||
|
|
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 | ||
|
|
45bffb53f9 | ||
|
|
cc15b2b4c3 | ||
|
|
11dfad88e6 | ||
|
|
572a81f2af | ||
|
|
ff4f2491b8 | ||
|
|
05fd870d1b | ||
|
|
c07ecbae52 | ||
|
|
e20f9e6cdf | ||
|
|
3f3f4e20e2 | ||
|
|
c2fb84251b | ||
|
|
61541e7502 | ||
|
|
579214c4d0 | ||
|
|
4a7651eb65 | ||
|
|
01a6724153 | ||
|
|
a6ca5b7cd1 | ||
|
|
bcca2bffc2 | ||
|
|
e80f96cc3b | ||
|
|
4550574e03 | ||
|
|
08565e8a98 | ||
|
|
fb20b7bc58 | ||
|
|
52df66cd56 | ||
|
|
784cebded4 | ||
|
|
4f75aa1c8f | ||
|
|
94811ab585 | ||
|
|
f3e6e85f99 | ||
|
|
7f2cb157c8 | ||
|
|
f94f366cf9 | ||
|
|
4429e1cc70 | ||
|
|
1ab2f8dd9d | ||
|
|
e5f39fbd34 | ||
|
|
947154639c | ||
|
|
4ee99c18cd | ||
|
|
5d2e2ee259 | ||
|
|
92841f2cd5 | ||
|
|
caa955b3ac | ||
|
|
4dddcf932a | ||
|
|
10fe0e3aae | ||
|
|
076fec267a | ||
|
|
b4a12ecc14 | ||
|
|
6cffb31b42 | ||
|
|
6aed97d6c8 | ||
|
|
cb7d5aa3a7 | ||
|
|
70fedb5a46 | ||
|
|
7798ea9c5c | ||
|
|
0b1d3c85fd | ||
|
|
3c1a59640c | ||
|
|
ccefc98160 | ||
|
|
79805d1dd7 | ||
|
|
5969901be5 | ||
|
|
5316f2b1cc | ||
|
|
9572cbdb61 | ||
|
|
7fe7bff3d8 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,12 +1,17 @@
|
||||
*.pyc
|
||||
.*.swp
|
||||
.*.sw?
|
||||
|
||||
/docs/_*
|
||||
/docs/generated
|
||||
/docs/filesystem.rst
|
||||
/.cache
|
||||
/.local
|
||||
/build
|
||||
/pySim.egg-info
|
||||
/smdpp-data/sm-dp-sessions
|
||||
/smdpp-data/sm-dp-sessions*
|
||||
dist
|
||||
tags
|
||||
smdpp-data/certs/DPtls/CERT_S_SM_DP_TLS_NIST.pem
|
||||
smdpp-data/certs/DPtls/CERT_S_SM_DP_TLS_BRP.pem
|
||||
smdpp-data/generated
|
||||
smdpp-data/certs/dhparam2048.pem
|
||||
|
||||
@@ -97,7 +97,7 @@ Please install the following dependencies:
|
||||
- pyscard
|
||||
- pyserial
|
||||
- pytlv
|
||||
- pyyaml >= 5.1
|
||||
- pyyaml >= 5.4
|
||||
- smpp.pdu (from `github.com/hologram-io/smpp.pdu`)
|
||||
- termcolor
|
||||
|
||||
|
||||
112
contrib/analyze_simaResponse.py
Executable file
112
contrib/analyze_simaResponse.py
Executable file
@@ -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)
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Connect smartcard to a remote server, so the remote server can take control and
|
||||
perform commands on it."""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
from osmocom.utils import b2h
|
||||
import websockets
|
||||
|
||||
from pySim.transport import init_reader, argparse_add_reader_args, LinkBase
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.wsrc.client_blocking import WsClientBlocking
|
||||
|
||||
logging.basicConfig(format="[%(levelname)s] %(asctime)s %(message)s", level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CardWsClientBlocking(WsClientBlocking):
|
||||
"""Implementation of the card (reader) client of the WSRC (WebSocket Remote Card) protocol"""
|
||||
|
||||
def __init__(self, ws_uri, tp: LinkBase):
|
||||
super().__init__(ws_uri)
|
||||
self.tp = tp
|
||||
|
||||
def perform_outbound_hello(self):
|
||||
hello_data = {
|
||||
'client_type': 'card',
|
||||
'atr': b2h(self.tp.get_atr()),
|
||||
# TODO: include various card information in the HELLO message
|
||||
}
|
||||
super().perform_outbound_hello(hello_data)
|
||||
|
||||
def handle_rx_c_apdu(self, rx: dict):
|
||||
"""handle an inbound APDU transceive command"""
|
||||
data, sw = self.tp.send_apdu(rx['command'])
|
||||
tx = {
|
||||
'response': data,
|
||||
'sw': sw,
|
||||
}
|
||||
self.tx_json('r_apdu', tx)
|
||||
|
||||
def handle_rx_disconnect(self, rx: dict):
|
||||
"""server tells us to disconnect"""
|
||||
self.tx_json('disconnect_ack')
|
||||
# FIXME: tear down connection and/or terminate entire program
|
||||
|
||||
def handle_rx_print(self, rx: dict):
|
||||
"""print a message (text) given by server to the local console/log"""
|
||||
print(rx['message'])
|
||||
# no response
|
||||
|
||||
def handle_rx_reset_req(self, rx: dict):
|
||||
"""server tells us to reset the card"""
|
||||
self.tp.reset()
|
||||
self.tx_json('reset_resp', {'atr': b2h(self.tp.get_atr())})
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
argparse_add_reader_args(parser)
|
||||
parser.add_argument("--uri", default="ws://localhost:8765/",
|
||||
help="URI of the sever to which to connect")
|
||||
|
||||
opts = parser.parse_args()
|
||||
|
||||
# open the card reader / slot
|
||||
logger.info("Initializing Card Reader...")
|
||||
tp = init_reader(opts)
|
||||
logger.info("Connecting to Card...")
|
||||
tp.connect()
|
||||
scc = SimCardCommands(transport=tp)
|
||||
logger.info("Detected Card with ATR: %s" % b2h(tp.get_atr()))
|
||||
|
||||
# TODO: gather various information about the card; print it
|
||||
|
||||
# create + connect the client to the server
|
||||
cl = CardWsClientBlocking(opts.uri, tp)
|
||||
logger.info("Connecting to remote server...")
|
||||
try:
|
||||
cl.connect()
|
||||
print("Successfully connected to Server")
|
||||
except ConnectionRefusedError as e:
|
||||
print(e)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# endless loop: wait for inbound command from server + execute it
|
||||
cl.rx_and_execute_cmd()
|
||||
# TODO: clean handling of websocket disconnect
|
||||
except websockets.exceptions.ConnectionClosedOK as e:
|
||||
print(e)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt as e:
|
||||
print(e.__class__.__name__)
|
||||
sys.exit(2)
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from websockets.asyncio.server import serve
|
||||
from websockets.exceptions import ConnectionClosedError
|
||||
from osmocom.utils import Hexstr, swap_nibbles
|
||||
|
||||
from pySim.utils import SwMatchstr, ResTuple, sw_match, dec_iccid
|
||||
from pySim.exceptions import SwMatchError
|
||||
|
||||
logging.basicConfig(format="[%(levelname)s] %(asctime)s %(message)s", level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
card_clients = set()
|
||||
user_clients = set()
|
||||
|
||||
class WsClient:
|
||||
def __init__(self, websocket, hello: dict):
|
||||
self.websocket = websocket
|
||||
self.hello = hello
|
||||
self.identity = {}
|
||||
|
||||
def __str__(self):
|
||||
return '%s(ws=%s)' % (self.__class__.__name__, self.websocket)
|
||||
|
||||
async def rx_json(self):
|
||||
rx = await self.websocket.recv()
|
||||
rx_js = json.loads(rx)
|
||||
logger.debug("Rx: %s", rx_js)
|
||||
assert 'msg_type' in rx
|
||||
return rx_js
|
||||
|
||||
async def tx_json(self, msg_type:str, d: dict = {}):
|
||||
"""Transmit a json-serializable dict to the peer"""
|
||||
d['msg_type'] = msg_type
|
||||
d_js = json.dumps(d)
|
||||
logger.debug("Tx: %s", d_js)
|
||||
await self.websocket.send(d_js)
|
||||
|
||||
async def tx_hello_ack(self):
|
||||
await self.tx_json('hello_ack')
|
||||
|
||||
async def xceive_json(self, msg_type:str, d:dict = {}, exp_msg_type:Optional[str] = None) -> dict:
|
||||
await self.tx_json(msg_type, d)
|
||||
rx = await self.rx_json()
|
||||
if exp_msg_type:
|
||||
assert rx['msg_type'] == exp_msg_type
|
||||
return rx;
|
||||
|
||||
async def tx_error(self, message: str):
|
||||
"""Transmit an error message to the peer"""
|
||||
event = {
|
||||
"message": message,
|
||||
}
|
||||
await self.tx_json('error', event)
|
||||
|
||||
async def ws_hdlr(self):
|
||||
"""kind of a 'main' function for the websocket client: wait for incoming message,
|
||||
and handle it."""
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
method = getattr(self, 'handle_rx_%s' % message['msg_type'], None)
|
||||
if not method:
|
||||
await self.tx_error("Unknonw msg_type: %s" % message['msg_type'])
|
||||
else:
|
||||
method(message)
|
||||
except ConnectionClosedError:
|
||||
# we handle this in the outer loop
|
||||
pass
|
||||
|
||||
class CardClient(WsClient):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.state = 'init'
|
||||
|
||||
def __str__(self):
|
||||
eid = self.identity.get('EID', None)
|
||||
if eid:
|
||||
return '%s(EID=%s)' % (self.__class__.__name__, eid)
|
||||
iccid = self.identity.get('ICCID', None)
|
||||
if iccid:
|
||||
return '%s(ICCID=%s)' % (self.__class__.__name__, iccid)
|
||||
return super().__str__()
|
||||
|
||||
"""A websocket client that represents a reader/card. This is what we use to talk to a card"""
|
||||
async def xceive_apdu_raw(self, cmd: Hexstr) -> ResTuple:
|
||||
"""transceive a single APDU with the card"""
|
||||
message = await self.xceive_json('c_apdu', {'command': cmd}, 'r_apdu')
|
||||
return message['response'], message['sw']
|
||||
|
||||
async def xceive_apdu(self, pdu: Hexstr) -> ResTuple:
|
||||
"""transceive an APDU with the card, handling T=0 GET_RESPONSE cases"""
|
||||
prev_pdu = pdu
|
||||
data, sw = await self.xceive_apdu_raw(pdu)
|
||||
|
||||
if sw is not None:
|
||||
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
|
||||
# SW1=61: ISO/IEC 7816-4, Table 5 — General meaning of the interindustry values of SW1-SW2
|
||||
# SW1=62: ETSI TS 102 221 7.3.1.1.4 Clause 4b): 62xx, 63xx, 9xxx != 9000
|
||||
pdu_gr = pdu[0:2] + 'c00000' + sw[2:4]
|
||||
prev_pdu = pdu_gr
|
||||
d, sw = await self.xceive_apdu_raw(pdu_gr)
|
||||
data += d
|
||||
if sw[0:2] == '6c':
|
||||
# SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding
|
||||
pdu_gr = prev_pdu[0:8] + sw[2:4]
|
||||
data, sw = await self.xceive_apdu_raw(pdu_gr)
|
||||
|
||||
return data, sw
|
||||
|
||||
async def xceive_apdu_checksw(self, pdu: Hexstr, sw: SwMatchstr = "9000") -> ResTuple:
|
||||
"""like xceive_apdu, but checking the status word matches the expected pattern"""
|
||||
rv = await self.xceive_apdu(pdu)
|
||||
last_sw = rv[1]
|
||||
|
||||
if not sw_match(rv[1], sw):
|
||||
raise SwMatchError(rv[1], sw.lower())
|
||||
return rv
|
||||
|
||||
async def card_reset(self):
|
||||
"""reset the card"""
|
||||
rx = await self.xceive_json('reset_req', exp_msg_type='reset_resp')
|
||||
|
||||
async def get_iccid(self):
|
||||
"""high-level method to obtain the ICCID of the card"""
|
||||
await self.xceive_apdu_checksw('00a40000023f00') # SELECT MF
|
||||
await self.xceive_apdu_checksw('00a40000022fe2') # SELECT EF.ICCID
|
||||
res, sw = await self.xceive_apdu_checksw('00b0000000') # READ BINARY
|
||||
return dec_iccid(res)
|
||||
|
||||
async def get_eid_sgp22(self):
|
||||
"""high-level method to obtain the EID of a SGP.22 consumer eUICC"""
|
||||
await self.xceive_apdu_checksw('00a4040410a0000005591010ffffffff8900000100')
|
||||
res, sw = await self.xceive_apdu_checksw('80e2910006bf3e035c015a')
|
||||
return res[-32:]
|
||||
|
||||
async def identify(self):
|
||||
# identify the card by asking for its EID and/or ICCID
|
||||
try:
|
||||
eid = await self.get_eid_sgp22()
|
||||
logger.debug("EID: %s", eid)
|
||||
self.identity['EID'] = eid
|
||||
except SwMatchError:
|
||||
pass
|
||||
try:
|
||||
iccid = await self.get_iccid()
|
||||
logger.debug("ICCID: %s", iccid)
|
||||
self.identity['ICCID'] = iccid
|
||||
except SwMatchError:
|
||||
pass
|
||||
logger.info("Card now in READY state")
|
||||
self.state = 'ready'
|
||||
|
||||
@staticmethod
|
||||
def find_client_for_id(id_type: str, id_str: str) -> Optional['CardClient']:
|
||||
for c in card_clients:
|
||||
print("testing card %s in state %s" % (c, c.state))
|
||||
if c.state != 'ready':
|
||||
continue
|
||||
c_id = c.identity.get(id_type.upper(), None)
|
||||
if c_id and c_id.lower() == id_str.lower():
|
||||
return c
|
||||
return None
|
||||
|
||||
class UserClient(WsClient):
|
||||
"""A websocket client representing a user application like pySim-shell."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.state = 'init'
|
||||
|
||||
async def state_init(self):
|
||||
"""Wait for incoming 'select_card' and process it."""
|
||||
while True:
|
||||
rx = await self.rx_json()
|
||||
if rx['msg_type'] == 'select_card':
|
||||
# look-up if the card can be found
|
||||
card = CardClient.find_client_for_id(rx['id_type'], rx['id_str'])
|
||||
if not card:
|
||||
await self.tx_error('No CardClient found for %s == %s' % (rx['id_type'], rx['id_str']))
|
||||
continue
|
||||
# transition to next statee
|
||||
self.state = 'associated'
|
||||
card.state = 'associated'
|
||||
self.card = card
|
||||
await self.tx_json('select_card_ack', {'identities': card.identity})
|
||||
break
|
||||
else:
|
||||
self.tx_error('Unknown message type %s' % rx['msg_type'])
|
||||
|
||||
async def state_selected(self):
|
||||
while True:
|
||||
rx = await self.rx_json()
|
||||
if rx['msg_type'] == 'c_apdu':
|
||||
rsp, sw = await self.card.xceive_apdu_raw(rx['command'])
|
||||
await self.tx_json('r_apdu', {'response': rsp, 'sw': sw})
|
||||
|
||||
|
||||
async def ws_conn_hdlr(websocket):
|
||||
rx_raw = await websocket.recv()
|
||||
rx = json.loads(rx_raw)
|
||||
assert rx['msg_type'] == 'hello'
|
||||
client_type = rx['client_type']
|
||||
logger.info("New client (type %s) connection accepted", client_type)
|
||||
|
||||
if client_type == 'card':
|
||||
card = CardClient(websocket, rx)
|
||||
await card.tx_hello_ack()
|
||||
card_clients.add(card)
|
||||
# first obtain the identity of the card
|
||||
await card.identify()
|
||||
# then go into the "main loop"
|
||||
try:
|
||||
await card.ws_hdlr()
|
||||
finally:
|
||||
logger.info("%s: connection closed", card)
|
||||
card_clients.remove(card)
|
||||
elif client_type == 'user':
|
||||
user = UserClient(websocket, rx)
|
||||
await user.tx_hello_ack()
|
||||
user_clients.add(user)
|
||||
# first wait for the user to specify the select the card
|
||||
await user.state_init()
|
||||
try:
|
||||
await user.state_selected()
|
||||
finally:
|
||||
logger.info("%s: connection closed", user)
|
||||
user_clients.remove(user)
|
||||
else:
|
||||
logger.info("Rejecting client (unknown type %s) connection", client_type)
|
||||
raise ValueError
|
||||
|
||||
|
||||
async def main():
|
||||
async with serve(ws_conn_hdlr, "localhost", 8765):
|
||||
await asyncio.get_running_loop().create_future() # run forever
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -24,20 +24,12 @@ import argparse
|
||||
from Cryptodome.Cipher import AES
|
||||
from osmocom.utils import h2b, b2h, Hexstr
|
||||
|
||||
from pySim.card_key_provider import CardKeyProviderCsv
|
||||
from pySim.card_key_provider import CardKeyFieldCryptor
|
||||
|
||||
def dict_keys_to_upper(d: dict) -> dict:
|
||||
return {k.upper():v for k,v in d.items()}
|
||||
|
||||
class CsvColumnEncryptor:
|
||||
class CsvColumnEncryptor(CardKeyFieldCryptor):
|
||||
def __init__(self, filename: str, transport_keys: dict):
|
||||
self.filename = filename
|
||||
self.transport_keys = dict_keys_to_upper(transport_keys)
|
||||
|
||||
def encrypt_col(self, colname:str, value: str) -> Hexstr:
|
||||
key = self.transport_keys[colname]
|
||||
cipher = AES.new(h2b(key), AES.MODE_CBC, CardKeyProviderCsv.IV)
|
||||
return b2h(cipher.encrypt(h2b(value)))
|
||||
self.crypt = CardKeyFieldCryptor(transport_keys)
|
||||
|
||||
def encrypt(self) -> None:
|
||||
with open(self.filename, 'r') as infile:
|
||||
@@ -49,9 +41,8 @@ class CsvColumnEncryptor:
|
||||
cw.writeheader()
|
||||
|
||||
for row in cr:
|
||||
for key_colname in self.transport_keys:
|
||||
if key_colname in row:
|
||||
row[key_colname] = self.encrypt_col(key_colname, row[key_colname])
|
||||
for fieldname in cr.fieldnames:
|
||||
row[fieldname] = self.crypt.encrypt_field(fieldname, row[fieldname])
|
||||
cw.writerow(row)
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -71,9 +62,5 @@ if __name__ == "__main__":
|
||||
print("You must specify at least one key!")
|
||||
sys.exit(1)
|
||||
|
||||
csv_column_keys = CardKeyProviderCsv.process_transport_keys(csv_column_keys)
|
||||
for name, key in csv_column_keys.items():
|
||||
print("Encrypting column %s using AES key %s" % (name, key))
|
||||
|
||||
cce = CsvColumnEncryptor(opts.CSVFILE, csv_column_keys)
|
||||
cce.encrypt()
|
||||
|
||||
301
contrib/csv-to-pgsql.py
Executable file
301
contrib/csv-to-pgsql.py
Executable file
@@ -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()
|
||||
@@ -24,7 +24,7 @@ ICCID_HELP='The ICCID of the eSIM that shall be made available'
|
||||
MATCHID_HELP='MatchingID that shall be used by profile download'
|
||||
|
||||
parser = argparse.ArgumentParser(description="""
|
||||
Utility to manuall issue requests against the ES2+ API of an SM-DP+ according to GSMA SGP.22.""")
|
||||
Utility to manually issue requests against the ES2+ API of an SM-DP+ according to GSMA SGP.22.""")
|
||||
parser.add_argument('--url', required=True, help='Base URL of ES2+ API endpoint')
|
||||
parser.add_argument('--id', required=True, help='Entity identifier passed to SM-DP+')
|
||||
parser.add_argument('--client-cert', help='X.509 client certificate used to authenticate to server')
|
||||
@@ -63,7 +63,7 @@ if __name__ == '__main__':
|
||||
data = {}
|
||||
for k, v in vars(opts).items():
|
||||
if k in ['url', 'id', 'client_cert', 'server_ca_cert', 'command']:
|
||||
# remove keys from dict that shold not end up in JSON...
|
||||
# remove keys from dict that should not end up in JSON...
|
||||
continue
|
||||
if v is not None:
|
||||
data[k] = v
|
||||
|
||||
100
contrib/es2p_server.py
Executable file
100
contrib/es2p_server.py
Executable file
@@ -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)
|
||||
|
||||
@@ -68,7 +68,7 @@ parser_dl.add_argument('--confirmation-code',
|
||||
# notification
|
||||
parser_ntf = subparsers.add_parser('notification', help='ES9+ (other) notification')
|
||||
parser_ntf.add_argument('operation', choices=['enable','disable','delete'],
|
||||
help='Profile Management Opreation whoise occurrence shall be notififed')
|
||||
help='Profile Management Operation whoise occurrence shall be notififed')
|
||||
parser_ntf.add_argument('--sequence-nr', type=int, required=True,
|
||||
help='eUICC global notification sequence number')
|
||||
parser_ntf.add_argument('--notification-address', help='notificationAddress, if different from URL')
|
||||
@@ -123,17 +123,17 @@ class Es9pClient:
|
||||
'profileManagementOperation': PMO(self.opts.operation).to_bitstring(),
|
||||
'notificationAddress': self.opts.notification_address or urlparse(self.opts.url).netloc,
|
||||
}
|
||||
if opts.iccid:
|
||||
ntf_metadata['iccid'] = h2b(swap_nibbles(opts.iccid))
|
||||
if self.opts.iccid:
|
||||
ntf_metadata['iccid'] = h2b(swap_nibbles(self.opts.iccid))
|
||||
|
||||
if self.opts.operation == 'download':
|
||||
if self.opts.operation == 'install':
|
||||
pird = {
|
||||
'transactionId': self.opts.transaction_id,
|
||||
'transactionId': h2b(self.opts.transaction_id),
|
||||
'notificationMetadata': ntf_metadata,
|
||||
'smdpOid': self.opts.smdpp_oid,
|
||||
'finalResult': ('successResult', {
|
||||
'aid': self.opts.isdp_aid,
|
||||
'simaResponse': self.opts.sima_response,
|
||||
'aid': h2b(self.opts.isdp_aid),
|
||||
'simaResponse': h2b(self.opts.sima_response),
|
||||
}),
|
||||
}
|
||||
pird_bin = rsp.asn1.encode('ProfileInstallationResultData', pird)
|
||||
|
||||
40
contrib/esim_gen_metadata.py
Executable file
40
contrib/esim_gen_metadata.py
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# (C) 2025 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 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 argparse
|
||||
from osmocom.utils import h2b, swap_nibbles
|
||||
from pySim.esim.es8p import ProfileMetadata
|
||||
|
||||
parser = argparse.ArgumentParser(description="""Utility program to generate profile metadata in the
|
||||
StoreMetadataRequest format based on input values from the command line.""")
|
||||
parser.add_argument('--iccid', required=True, help="ICCID of eSIM profile");
|
||||
parser.add_argument('--spn', required=True, help="Service Provider Name");
|
||||
parser.add_argument('--profile-name', required=True, help="eSIM Profile Name");
|
||||
parser.add_argument('--profile-class', choices=['test', 'operational', 'provisioning'],
|
||||
default='operational', help="Profile Class");
|
||||
parser.add_argument('--outfile', required=True, help="Output File Name");
|
||||
|
||||
if __name__ == '__main__':
|
||||
opts = parser.parse_args()
|
||||
|
||||
iccid_bin = h2b(swap_nibbles(opts.iccid))
|
||||
pmd = ProfileMetadata(iccid_bin, spn=opts.spn, profile_name=opts.profile_name,
|
||||
profile_class=opts.profile_class)
|
||||
|
||||
with open(opts.outfile, 'wb') as f:
|
||||
f.write(pmd.gen_store_metadata_request())
|
||||
print("Written StoreMetadataRequest to '%s'" % opts.outfile)
|
||||
@@ -1,206 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# (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 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/>.
|
||||
|
||||
|
||||
# This is a script to generate a [partial] eSIM profile from the 'fsdump' of another USIM/ISIM. This is
|
||||
# useful to generate an "as close as possible" eSIM from a physical USIM, as far as that is possible
|
||||
# programmatically and in a portable way.
|
||||
#
|
||||
# Of course, this really only affects the file sytem aspects of the card. It's not possible
|
||||
# to read the K/OPc or other authentication related parameters off a random USIM, and hence
|
||||
# we cannot replicate that. Similarly, it's not possible to export the java applets from a USIM,
|
||||
# and hence we cannot replicate those.
|
||||
|
||||
import argparse
|
||||
|
||||
from pySim.esim.saip import *
|
||||
from pySim.ts_102_221 import *
|
||||
|
||||
class FsdumpToSaip:
|
||||
def __init__(self, pes: ProfileElementSequence):
|
||||
self.pes = pes
|
||||
|
||||
@staticmethod
|
||||
def fcp_raw2saip_fcp(fname:str, fcp_raw: bytes) -> Dict:
|
||||
"""Convert a raw TLV-encoded FCP to a SAIP dict-format (as needed by asn1encode)."""
|
||||
ftype = fname.split('.')[0]
|
||||
# use the raw FCP as basis so we don't get stuck with potentially old decoder bugs
|
||||
# ore future format incompatibilities
|
||||
fcp = FcpTemplate()
|
||||
fcp.from_tlv(fcp_raw)
|
||||
r = {}
|
||||
|
||||
r['fileDescriptor'] = fcp.child_by_type(FileDescriptor).to_bytes()
|
||||
|
||||
file_id = fcp.child_by_type(FileIdentifier)
|
||||
if file_id:
|
||||
r['fileID'] = file_id.to_bytes()
|
||||
else:
|
||||
if ftype in ['ADF']:
|
||||
print('%s is an ADF but has no [mandatory] file_id!' % fname)
|
||||
#raise ValueError('%s is an ADF but has no [mandatory] file_id!' % fname)
|
||||
r['fileID'] = b'\x7f\xff' # FIXME: auto-increment
|
||||
|
||||
df_name = fcp.child_by_type(DfName)
|
||||
if ftype in ['ADF']:
|
||||
if not df_name:
|
||||
raise ValueError('%s is an ADF but has no [mandatory] df_name!' % fname)
|
||||
r['dfName'] = df_name.to_bytes()
|
||||
|
||||
lcsi_byte = fcp.child_by_type(LifeCycleStatusInteger).to_bytes()
|
||||
if lcsi_byte != b'\x05':
|
||||
r['lcsi'] = lcsi_byte
|
||||
|
||||
sa_ref = fcp.child_by_type(SecurityAttribReferenced)
|
||||
if sa_ref:
|
||||
r['securityAttributesReferenced'] = sa_ref.to_bytes()
|
||||
|
||||
file_size = fcp.child_by_type(FileSize)
|
||||
if ftype in ['EF']:
|
||||
if file_size:
|
||||
r['efFileSize'] = file_size.to_bytes()
|
||||
|
||||
psdo = fcp.child_by_type(PinStatusTemplate_DO)
|
||||
if ftype in ['MF', 'ADF', 'DF']:
|
||||
if not psdo:
|
||||
raise ValueError('%s is an %s but has no [mandatory] PinStatusTemplateDO' % fname)
|
||||
else:
|
||||
r['pinStatusTemplateDO'] = psdo.to_bytes()
|
||||
|
||||
sfid = fcp.child_by_type(ShortFileIdentifier)
|
||||
if sfid and sfid.decoded:
|
||||
if ftype not in ['EF']:
|
||||
raise ValueError('%s is not an EF but has [forbidden] shortEFID' % fname)
|
||||
r['shortEFID'] = sfid.to_bytes()
|
||||
|
||||
pinfo = fcp.child_by_type(ProprietaryInformation)
|
||||
if pinfo and ftype in ['EF']:
|
||||
spinfo = pinfo.child_by_type(SpecialFileInfo)
|
||||
fill_p = pinfo.child_by_type(FillingPattern)
|
||||
repeat_p = pinfo.child_by_type(RepeatPattern)
|
||||
# only exists for BER-TLV files
|
||||
max_fsize = pinfo.child_by_type(MaximumFileSize)
|
||||
|
||||
if spinfo or fill_p or repeat_p or max_fsize:
|
||||
r['proprietaryEFInfo'] = {}
|
||||
if spinfo:
|
||||
r['proprietaryEFInfo']['specialFileInformation'] = spinfo.to_bytes()
|
||||
if fill_p:
|
||||
r['proprietaryEFInfo']['fillPattern'] = fill_p.to_bytes()
|
||||
if repeat_p:
|
||||
r['proprietaryEFInfo']['repeatPattern'] = repeat_p.to_bytes()
|
||||
if max_fsize:
|
||||
r['proprietaryEFInfo']['maximumFileSize'] = max_fsize.to_bytes()
|
||||
|
||||
# TODO: linkPath
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
def fcp_fsdump2saip(fsdump_ef: Dict):
|
||||
"""Convert a file from its "fsdump" representation to the SAIP representation of a File type
|
||||
in the decoded format as used by the asn1tools-generated codec."""
|
||||
# first convert the FCP
|
||||
path = fsdump_ef['path']
|
||||
fdesc = FsdumpToSaip.fcp_raw2saip_fcp(path[-1], h2b(fsdump_ef['fcp_raw']))
|
||||
r = [
|
||||
('fileDescriptor', fdesc),
|
||||
]
|
||||
# then convert the body. We're doing a straight-forward conversion without any optimization
|
||||
# like not encoding all-ff files. This should be done by a subsequent optimizer
|
||||
if 'body' in fsdump_ef and fsdump_ef['body']:
|
||||
if isinstance(fsdump_ef['body'], list):
|
||||
for b_seg in fsdump_ef['body']:
|
||||
r.append(('fillFileContent', h2b(b_seg)))
|
||||
else:
|
||||
r.append(('fillFileContent', h2b(fsdump_ef['body'])))
|
||||
print(fsdump_ef['path'])
|
||||
return r
|
||||
|
||||
def add_file_from_fsdump(self, fsdump_ef: Dict):
|
||||
fid = int(fsdump_ef['fcp']['file_identifier'])
|
||||
# determine NAA
|
||||
if fsdump_ef['path'][0:1] == ['MF', 'ADF.USIM']:
|
||||
naa = NaaUsim
|
||||
elif fsdump_ef['path'][0:1] == ['MF', 'ADF.ISIM']:
|
||||
naa = NaaIsim
|
||||
else:
|
||||
print("Unable to determine NAA for %s" % fsdump_ef['path'])
|
||||
return
|
||||
pes.pes_by_naa[naa.name]
|
||||
for pe in pes:
|
||||
print("PE %s" % pe)
|
||||
if not isinstance(pe, FsProfileElement):
|
||||
print("Skipping PE %s" % pe)
|
||||
continue
|
||||
if not pe.template:
|
||||
print("No template for PE %s" % pe )
|
||||
continue
|
||||
if not fid in pe.template.files_by_fid:
|
||||
print("File %04x not available in template; must create it using GenericFileMgmt" % fid)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('fsdump', help='')
|
||||
|
||||
def has_unsupported_path_prefix(path: List[str]) -> bool:
|
||||
# skip some paths from export as they don't exist in an eSIM profile
|
||||
UNSUPPORTED_PATHS = [
|
||||
['MF', 'DF.GSM'],
|
||||
]
|
||||
for p in UNSUPPORTED_PATHS:
|
||||
prefix = path[:len(p)]
|
||||
if prefix == p:
|
||||
return True
|
||||
# any ADF not USIM or ISIM are unsupported
|
||||
SUPPORTED_ADFS = [ 'ADF.USIM', 'ADF.ISIM' ]
|
||||
if len(path) == 2 and path[0] == 'MF' and path[1].startswith('ADF.') and path[1] not in SUPPORTED_ADFS:
|
||||
return True
|
||||
return False
|
||||
|
||||
import traceback
|
||||
|
||||
if __name__ == '__main__':
|
||||
opts = parser.parse_args()
|
||||
|
||||
with open(opts.fsdump, 'r') as f:
|
||||
fsdump = json.loads(f.read())
|
||||
|
||||
pes = ProfileElementSequence()
|
||||
|
||||
# FIXME: fsdump has strting-name path, but we need FID-list path for ProfileElementSequence
|
||||
for path, fsdump_ef in fsdump['files'].items():
|
||||
print("=" * 80)
|
||||
#print(fsdump_ef)
|
||||
if not 'fcp_raw' in fsdump_ef:
|
||||
continue
|
||||
if has_unsupported_path_prefix(fsdump_ef['path']):
|
||||
print("Skipping eSIM-unsupported path %s" % ('/'.join(fsdump_ef['path'])))
|
||||
continue
|
||||
saip_dec = FsdumpToSaip.fcp_fsdump2saip(fsdump_ef)
|
||||
#print(saip_dec)
|
||||
try:
|
||||
f = pes.add_file_at_path(Path(path), saip_dec)
|
||||
print(repr(f))
|
||||
except Exception as e:
|
||||
print("EXCEPTION: %s" % traceback.format_exc())
|
||||
#print("EXCEPTION: %s" % e)
|
||||
continue
|
||||
|
||||
print("=== Tree === ")
|
||||
pes.mf.print_tree()
|
||||
|
||||
# FIXME: export the actual PE Sequence
|
||||
|
||||
661
contrib/generate_smdpp_certs.py
Executable file
661
contrib/generate_smdpp_certs.py
Executable file
@@ -0,0 +1,661 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Faithfully reproduces the smdpp certs contained in SGP.26_v1.5_Certificates_18_07_2024.zip
|
||||
available at https://www.gsma.com/solutions-and-impact/technologies/esim/gsma_resources/sgp-26-test-certificate-definition-v1-5/
|
||||
Only usable for testing, it obviously uses a different CI key.
|
||||
"""
|
||||
|
||||
import os
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID, ExtensionOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# Custom OIDs used in certificates
|
||||
OID_CERTIFICATE_POLICIES_CI = "2.23.146.1.2.1.0" # CI cert policy
|
||||
OID_CERTIFICATE_POLICIES_TLS = "2.23.146.1.2.1.3" # DPtls cert policy
|
||||
OID_CERTIFICATE_POLICIES_AUTH = "2.23.146.1.2.1.4" # DPauth cert policy
|
||||
OID_CERTIFICATE_POLICIES_PB = "2.23.146.1.2.1.5" # DPpb cert policy
|
||||
|
||||
# Subject Alternative Name OIDs
|
||||
OID_CI_RID = "2.999.1" # CI Registered ID
|
||||
OID_DP_RID = "2.999.10" # DP+ Registered ID
|
||||
OID_DP2_RID = "2.999.12" # DP+2 Registered ID
|
||||
OID_DP4_RID = "2.999.14" # DP+4 Registered ID
|
||||
OID_DP8_RID = "2.999.18" # DP+8 Registered ID
|
||||
|
||||
|
||||
class SimplifiedCertificateGenerator:
|
||||
def __init__(self):
|
||||
self.backend = default_backend()
|
||||
# Store generated CI keys to sign other certs
|
||||
self.ci_certs = {} # {"BRP": cert, "NIST": cert}
|
||||
self.ci_keys = {} # {"BRP": key, "NIST": key}
|
||||
|
||||
def get_curve(self, curve_type):
|
||||
"""Get the appropriate curve object."""
|
||||
if curve_type == "BRP":
|
||||
return ec.BrainpoolP256R1()
|
||||
else:
|
||||
return ec.SECP256R1()
|
||||
|
||||
def generate_key_pair(self, curve):
|
||||
"""Generate a new EC key pair."""
|
||||
private_key = ec.generate_private_key(curve, self.backend)
|
||||
return private_key
|
||||
|
||||
def load_private_key_from_hex(self, hex_key, curve):
|
||||
"""Load EC private key from hex string."""
|
||||
key_bytes = binascii.unhexlify(hex_key.replace(":", "").replace(" ", "").replace("\n", ""))
|
||||
key_int = int.from_bytes(key_bytes, 'big')
|
||||
return ec.derive_private_key(key_int, curve, self.backend)
|
||||
|
||||
def generate_ci_cert(self, curve_type):
|
||||
"""Generate CI certificate for either BRP or NIST curve."""
|
||||
curve = self.get_curve(curve_type)
|
||||
private_key = self.generate_key_pair(curve)
|
||||
|
||||
# Build subject and issuer (self-signed) - same for both
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "Test CI"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "TESTCERT"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "RSPTEST"),
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
|
||||
])
|
||||
|
||||
# Build certificate - all parameters same for both
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(issuer)
|
||||
builder = builder.not_valid_before(datetime(2020, 4, 1, 8, 27, 51))
|
||||
builder = builder.not_valid_after(datetime(2055, 4, 1, 8, 27, 51))
|
||||
builder = builder.serial_number(0xb874f3abfa6c44d3)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
|
||||
# Add extensions - all same for both
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=None),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CertificatePolicies([
|
||||
x509.PolicyInformation(
|
||||
x509.ObjectIdentifier(OID_CERTIFICATE_POLICIES_CI),
|
||||
policy_qualifiers=None
|
||||
)
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=False,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=True,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.RegisteredID(x509.ObjectIdentifier(OID_CI_RID))
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CRLDistributionPoints([
|
||||
x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier("http://ci.test.example.com/CRL-A.crl")],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
),
|
||||
x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier("http://ci.test.example.com/CRL-B.crl")],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
)
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
certificate = builder.sign(private_key, hashes.SHA256(), self.backend)
|
||||
|
||||
self.ci_keys[curve_type] = private_key
|
||||
self.ci_certs[curve_type] = certificate
|
||||
|
||||
return certificate, private_key
|
||||
|
||||
def generate_dp_cert(self, curve_type, subject_cn, serial, key_hex,
|
||||
cert_policy_oid, rid_oid, validity_start, validity_end):
|
||||
"""Generate a DP certificate signed by CI - works for both BRP and NIST."""
|
||||
curve = self.get_curve(curve_type)
|
||||
private_key = self.load_private_key_from_hex(key_hex, curve)
|
||||
|
||||
ci_cert = self.ci_certs[curve_type]
|
||||
ci_key = self.ci_keys[curve_type]
|
||||
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ACME"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, subject_cn),
|
||||
])
|
||||
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(ci_cert.subject)
|
||||
builder = builder.not_valid_before(validity_start)
|
||||
builder = builder.not_valid_after(validity_end)
|
||||
builder = builder.serial_number(serial)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_public_key(ci_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.RegisteredID(x509.ObjectIdentifier(rid_oid))
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CertificatePolicies([
|
||||
x509.PolicyInformation(
|
||||
x509.ObjectIdentifier(cert_policy_oid),
|
||||
policy_qualifiers=None
|
||||
)
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CRLDistributionPoints([
|
||||
x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier("http://ci.test.example.com/CRL-A.crl")],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
),
|
||||
x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier("http://ci.test.example.com/CRL-B.crl")],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
)
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
certificate = builder.sign(ci_key, hashes.SHA256(), self.backend)
|
||||
|
||||
return certificate, private_key
|
||||
|
||||
def generate_tls_cert(self, curve_type, subject_cn, dns_name, serial, key_hex,
|
||||
rid_oid, validity_start, validity_end):
|
||||
"""Generate a TLS certificate signed by CI."""
|
||||
curve = self.get_curve(curve_type)
|
||||
private_key = self.load_private_key_from_hex(key_hex, curve)
|
||||
|
||||
ci_cert = self.ci_certs[curve_type]
|
||||
ci_key = self.ci_keys[curve_type]
|
||||
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ACME"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, subject_cn),
|
||||
])
|
||||
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(ci_cert.subject)
|
||||
builder = builder.not_valid_before(validity_start)
|
||||
builder = builder.not_valid_after(validity_end)
|
||||
builder = builder.serial_number(serial)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.ExtendedKeyUsage([
|
||||
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
|
||||
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CertificatePolicies([
|
||||
x509.PolicyInformation(
|
||||
x509.ObjectIdentifier(OID_CERTIFICATE_POLICIES_TLS),
|
||||
policy_qualifiers=None
|
||||
)
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_public_key(ci_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(dns_name),
|
||||
x509.RegisteredID(x509.ObjectIdentifier(rid_oid))
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CRLDistributionPoints([
|
||||
x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier("http://ci.test.example.com/CRL-A.crl")],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
),
|
||||
x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier("http://ci.test.example.com/CRL-B.crl")],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
)
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
certificate = builder.sign(ci_key, hashes.SHA256(), self.backend)
|
||||
|
||||
return certificate, private_key
|
||||
|
||||
def generate_eum_cert(self, curve_type, key_hex):
|
||||
"""Generate EUM certificate signed by CI."""
|
||||
curve = self.get_curve(curve_type)
|
||||
private_key = self.load_private_key_from_hex(key_hex, curve)
|
||||
|
||||
ci_cert = self.ci_certs[curve_type]
|
||||
ci_key = self.ci_keys[curve_type]
|
||||
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "ES"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "RSP Test EUM"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "EUM Test"),
|
||||
])
|
||||
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(ci_cert.subject)
|
||||
builder = builder.not_valid_before(datetime(2020, 4, 1, 9, 28, 37))
|
||||
builder = builder.not_valid_after(datetime(2054, 3, 24, 9, 28, 37))
|
||||
builder = builder.serial_number(0x12345678)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_public_key(ci_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=False,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CertificatePolicies([
|
||||
x509.PolicyInformation(
|
||||
x509.ObjectIdentifier("2.23.146.1.2.1.2"), # EUM policy
|
||||
policy_qualifiers=None
|
||||
)
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.RegisteredID(x509.ObjectIdentifier("2.999.5"))
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=0),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CRLDistributionPoints([
|
||||
x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier("http://ci.test.example.com/CRL-B.crl")],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
)
|
||||
]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
# Name Constraints
|
||||
constrained_name = x509.Name([
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "RSP Test EUM"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, "89049032"),
|
||||
])
|
||||
|
||||
name_constraints = x509.NameConstraints(
|
||||
permitted_subtrees=[
|
||||
x509.DirectoryName(constrained_name)
|
||||
],
|
||||
excluded_subtrees=None
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
name_constraints,
|
||||
critical=True
|
||||
)
|
||||
|
||||
certificate = builder.sign(ci_key, hashes.SHA256(), self.backend)
|
||||
|
||||
return certificate, private_key
|
||||
|
||||
def generate_euicc_cert(self, curve_type, eum_cert, eum_key, key_hex):
|
||||
"""Generate eUICC certificate signed by EUM."""
|
||||
curve = self.get_curve(curve_type)
|
||||
private_key = self.load_private_key_from_hex(key_hex, curve)
|
||||
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "ES"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "RSP Test EUM"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, "89049032123451234512345678901235"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "Test eUICC"),
|
||||
])
|
||||
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(eum_cert.subject)
|
||||
builder = builder.not_valid_before(datetime(2020, 4, 1, 9, 48, 58))
|
||||
builder = builder.not_valid_after(datetime(7496, 1, 24, 9, 48, 58))
|
||||
builder = builder.serial_number(0x0200000000000001)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_public_key(eum_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
),
|
||||
critical=True
|
||||
)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.CertificatePolicies([
|
||||
x509.PolicyInformation(
|
||||
x509.ObjectIdentifier("2.23.146.1.2.1.1"), # eUICC policy
|
||||
policy_qualifiers=None
|
||||
)
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
|
||||
certificate = builder.sign(eum_key, hashes.SHA256(), self.backend)
|
||||
|
||||
return certificate, private_key
|
||||
|
||||
def save_cert_and_key(self, cert, key, cert_path_der, cert_path_pem, key_path_sk, key_path_pk):
|
||||
"""Save certificate and key in various formats."""
|
||||
# Create directories if needed
|
||||
os.makedirs(os.path.dirname(cert_path_der), exist_ok=True)
|
||||
|
||||
with open(cert_path_der, "wb") as f:
|
||||
f.write(cert.public_bytes(serialization.Encoding.DER))
|
||||
|
||||
if cert_path_pem:
|
||||
with open(cert_path_pem, "wb") as f:
|
||||
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
if key and key_path_sk:
|
||||
with open(key_path_sk, "wb") as f:
|
||||
f.write(key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
))
|
||||
|
||||
if key and key_path_pk:
|
||||
with open(key_path_pk, "wb") as f:
|
||||
f.write(key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
))
|
||||
|
||||
|
||||
def main():
|
||||
gen = SimplifiedCertificateGenerator()
|
||||
|
||||
output_dir = "smdpp-data/generated"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
print("=== Generating CI Certificates ===")
|
||||
|
||||
for curve_type in ["BRP", "NIST"]:
|
||||
ci_cert, ci_key = gen.generate_ci_cert(curve_type)
|
||||
suffix = "_ECDSA_BRP" if curve_type == "BRP" else "_ECDSA_NIST"
|
||||
gen.save_cert_and_key(
|
||||
ci_cert, ci_key,
|
||||
f"{output_dir}/CertificateIssuer/CERT_CI{suffix}.der",
|
||||
f"{output_dir}/CertificateIssuer/CERT_CI{suffix}.pem",
|
||||
None, None
|
||||
)
|
||||
print(f"Generated CI {curve_type} certificate")
|
||||
|
||||
print("\n=== Generating DPauth Certificates ===")
|
||||
|
||||
dpauth_configs = [
|
||||
("BRP", "TEST SM-DP+", 256, "93:fb:33:d0:58:4f:34:9b:07:f8:b5:d2:af:93:d7:c3:e3:54:b3:49:a3:b9:13:50:2e:6a:bc:07:0e:4d:49:29", OID_DP_RID, "DPauth"),
|
||||
("NIST", "TEST SM-DP+", 256, "0a:7c:c1:c2:44:e6:0c:52:cd:5b:78:07:ab:8c:36:0c:26:52:46:01:50:7d:ca:bc:5d:d5:98:b5:a6:16:d5:d5", OID_DP_RID, "DPauth"),
|
||||
("BRP", "TEST SM-DP+2", 512, "0c:17:35:5c:01:1d:0f:e8:d7:da:dd:63:f1:97:85:cf:6c:51:cb:cd:46:6a:e8:8b:e8:f8:1b:c1:05:88:46:f6", OID_DP2_RID, "DP2auth"),
|
||||
("NIST", "TEST SM-DP+2", 512, "9c:32:a0:95:d4:88:42:d9:ff:a4:04:f7:12:51:2a:a2:c5:42:5a:1a:26:38:6a:b6:a1:45:d5:81:1e:03:91:41", OID_DP2_RID, "DP2auth"),
|
||||
]
|
||||
|
||||
for curve_type, cn, serial, key_hex, rid_oid, name_prefix in dpauth_configs:
|
||||
cert, key = gen.generate_dp_cert(
|
||||
curve_type, cn, serial, key_hex,
|
||||
OID_CERTIFICATE_POLICIES_AUTH, rid_oid,
|
||||
datetime(2020, 4, 1, 8, 31, 30),
|
||||
datetime(2030, 3, 30, 8, 31, 30)
|
||||
)
|
||||
suffix = "_ECDSA_BRP" if curve_type == "BRP" else "_ECDSA_NIST"
|
||||
gen.save_cert_and_key(
|
||||
cert, key,
|
||||
f"{output_dir}/DPauth/CERT_S_SM_{name_prefix}{suffix}.der",
|
||||
None,
|
||||
f"{output_dir}/DPauth/SK_S_SM_{name_prefix}{suffix}.pem",
|
||||
f"{output_dir}/DPauth/PK_S_SM_{name_prefix}{suffix}.pem"
|
||||
)
|
||||
print(f"Generated {name_prefix} {curve_type} certificate")
|
||||
|
||||
print("\n=== Generating DPpb Certificates ===")
|
||||
|
||||
dppb_configs = [
|
||||
("BRP", "TEST SM-DP+", 257, "75:ff:32:2f:41:66:16:da:e1:a4:84:ef:71:d4:87:4f:b0:df:32:95:fd:35:c2:cb:a4:89:fb:b2:bb:9c:7b:f6", OID_DP_RID, "DPpb"),
|
||||
("NIST", "TEST SM-DP+", 257, "dc:d6:94:b7:78:95:7e:8e:9a:dd:bd:d9:44:33:e9:ef:8f:73:d1:1e:49:1c:48:d4:25:a3:8a:94:91:bd:3b:ed", OID_DP_RID, "DPpb"),
|
||||
("BRP", "TEST SM-DP+2", 513, "9c:ae:2e:1a:56:07:a9:d5:78:38:2e:ee:93:2e:25:1f:52:30:4f:86:ee:b1:f1:70:8c:db:d3:c0:7b:e2:cd:3d", OID_DP2_RID, "DP2pb"),
|
||||
("NIST", "TEST SM-DP+2", 513, "66:93:11:49:63:9d:ba:ac:1d:c3:d3:06:c5:8b:d2:df:d2:2f:73:bf:63:ac:86:31:98:32:90:b5:7f:90:93:45", OID_DP2_RID, "DP2pb"),
|
||||
]
|
||||
|
||||
for curve_type, cn, serial, key_hex, rid_oid, name_prefix in dppb_configs:
|
||||
cert, key = gen.generate_dp_cert(
|
||||
curve_type, cn, serial, key_hex,
|
||||
OID_CERTIFICATE_POLICIES_PB, rid_oid,
|
||||
datetime(2020, 4, 1, 8, 34, 46),
|
||||
datetime(2030, 3, 30, 8, 34, 46)
|
||||
)
|
||||
suffix = "_ECDSA_BRP" if curve_type == "BRP" else "_ECDSA_NIST"
|
||||
gen.save_cert_and_key(
|
||||
cert, key,
|
||||
f"{output_dir}/DPpb/CERT_S_SM_{name_prefix}{suffix}.der",
|
||||
None,
|
||||
f"{output_dir}/DPpb/SK_S_SM_{name_prefix}{suffix}.pem",
|
||||
f"{output_dir}/DPpb/PK_S_SM_{name_prefix}{suffix}.pem"
|
||||
)
|
||||
print(f"Generated {name_prefix} {curve_type} certificate")
|
||||
|
||||
print("\n=== Generating DPtls Certificates ===")
|
||||
|
||||
dptls_configs = [
|
||||
("BRP", "testsmdpplus1.example.com", "testsmdpplus1.example.com", 9, "3f:67:15:28:02:b3:f4:c7:fa:e6:79:58:55:f6:82:54:1e:45:e3:5e:ff:f4:e8:a0:55:65:a0:f1:91:2a:78:2e", OID_DP_RID, "DP_TLS_BRP"),
|
||||
("NIST", "testsmdpplus1.example.com", "testsmdpplus1.example.com", 9, "a0:3e:7c:e4:55:04:74:be:a4:b7:a8:73:99:ce:5a:8c:9f:66:1b:68:0f:94:01:39:ff:f8:4e:9d:ec:6a:4d:8c", OID_DP_RID, "DP_TLS_NIST"),
|
||||
("NIST", "testsmdpplus2.example.com", "testsmdpplus2.example.com", 12, "4e:65:61:c6:40:88:f6:69:90:7a:db:e3:94:b1:1a:84:24:2e:03:3a:82:a8:84:02:31:63:6d:c9:1b:4e:e3:f5", OID_DP2_RID, "DP2_TLS"),
|
||||
("NIST", "testsmdpplus4.example.com", "testsmdpplus4.example.com", 14, "f2:65:9d:2f:52:8f:4b:11:37:40:d5:8a:0d:2a:f3:eb:2b:48:e1:22:c2:b6:0a:6a:f6:fc:96:ad:86:be:6f:a4", OID_DP4_RID, "DP4_TLS"),
|
||||
("NIST", "testsmdpplus8.example.com", "testsmdpplus8.example.com", 18, "ff:6e:4a:50:9b:ad:db:38:10:88:31:c2:3c:cc:2d:44:30:7a:f2:81:e9:25:96:7f:8c:df:1d:95:54:a0:28:8d", OID_DP8_RID, "DP8_TLS"),
|
||||
]
|
||||
|
||||
for curve_type, cn, dns, serial, key_hex, rid_oid, name_prefix in dptls_configs:
|
||||
cert, key = gen.generate_tls_cert(
|
||||
curve_type, cn, dns, serial, key_hex, rid_oid,
|
||||
datetime(2024, 7, 9, 15, 29, 36),
|
||||
datetime(2025, 8, 11, 15, 29, 36)
|
||||
)
|
||||
gen.save_cert_and_key(
|
||||
cert, key,
|
||||
f"{output_dir}/DPtls/CERT_S_SM_{name_prefix}.der",
|
||||
None,
|
||||
f"{output_dir}/DPtls/SK_S_SM_{name_prefix.replace('_BRP', '_BRP').replace('_NIST', '_NIST')}.pem",
|
||||
f"{output_dir}/DPtls/PK_S_SM_{name_prefix.replace('_BRP', '_BRP').replace('_NIST', '_NIST')}.pem"
|
||||
)
|
||||
print(f"Generated {name_prefix} certificate")
|
||||
|
||||
print("\n=== Generating EUM Certificates ===")
|
||||
|
||||
eum_configs = [
|
||||
("BRP", "12:9b:0a:b1:3f:17:e1:4a:40:b6:fa:4e:d8:23:e0:cf:46:5b:7b:3d:73:24:05:e6:29:5d:3b:23:b0:45:c9:9a"),
|
||||
("NIST", "25:e6:75:77:28:e1:e9:51:13:51:9c:dc:34:55:5c:29:ba:ed:23:77:3a:c5:af:dd:dc:da:d9:84:89:8a:52:f0"),
|
||||
]
|
||||
|
||||
eum_certs = {}
|
||||
eum_keys = {}
|
||||
|
||||
for curve_type, key_hex in eum_configs:
|
||||
cert, key = gen.generate_eum_cert(curve_type, key_hex)
|
||||
eum_certs[curve_type] = cert
|
||||
eum_keys[curve_type] = key
|
||||
suffix = "_ECDSA_BRP" if curve_type == "BRP" else "_ECDSA_NIST"
|
||||
gen.save_cert_and_key(
|
||||
cert, key,
|
||||
f"{output_dir}/EUM/CERT_EUM{suffix}.der",
|
||||
None,
|
||||
f"{output_dir}/EUM/SK_EUM{suffix}.pem",
|
||||
f"{output_dir}/EUM/PK_EUM{suffix}.pem"
|
||||
)
|
||||
print(f"Generated EUM {curve_type} certificate")
|
||||
|
||||
print("\n=== Generating eUICC Certificates ===")
|
||||
|
||||
euicc_configs = [
|
||||
("BRP", "8d:c3:47:a7:6d:b7:bd:d6:22:2d:d7:5e:a1:a1:68:8a:ca:81:1e:4c:bc:6a:7f:6a:ef:a4:b2:64:19:62:0b:90"),
|
||||
("NIST", "11:e1:54:67:dc:19:4f:33:71:83:e4:60:c9:f6:32:60:09:1e:12:e8:10:26:cd:65:61:e1:7c:6d:85:39:cc:9c"),
|
||||
]
|
||||
|
||||
for curve_type, key_hex in euicc_configs:
|
||||
cert, key = gen.generate_euicc_cert(curve_type, eum_certs[curve_type], eum_keys[curve_type], key_hex)
|
||||
suffix = "_ECDSA_BRP" if curve_type == "BRP" else "_ECDSA_NIST"
|
||||
gen.save_cert_and_key(
|
||||
cert, key,
|
||||
f"{output_dir}/eUICC/CERT_EUICC{suffix}.der",
|
||||
None,
|
||||
f"{output_dir}/eUICC/SK_EUICC{suffix}.pem",
|
||||
f"{output_dir}/eUICC/PK_EUICC{suffix}.pem"
|
||||
)
|
||||
print(f"Generated eUICC {curve_type} certificate")
|
||||
|
||||
print("\n=== Certificate generation complete! ===")
|
||||
print(f"All certificates saved to: {output_dir}/")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,6 +10,11 @@
|
||||
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
setup_venv() {
|
||||
virtualenv -p python3 venv --system-site-packages
|
||||
. venv/bin/activate
|
||||
}
|
||||
|
||||
if [ ! -d "./tests/" ] ; then
|
||||
echo "###############################################"
|
||||
echo "Please call from pySim-prog top directory"
|
||||
@@ -23,8 +28,7 @@ fi
|
||||
|
||||
case "$JOB_TYPE" in
|
||||
"test")
|
||||
virtualenv -p python3 venv --system-site-packages
|
||||
. venv/bin/activate
|
||||
setup_venv
|
||||
|
||||
pip install -r requirements.txt
|
||||
pip install pyshark
|
||||
@@ -32,20 +36,27 @@ case "$JOB_TYPE" in
|
||||
# Execute automatically discovered unit tests first
|
||||
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
|
||||
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/
|
||||
|
||||
# Run pySim-smpp2sim test
|
||||
tests/pySim-smpp2sim_test/pySim-smpp2sim_test.sh
|
||||
;;
|
||||
"distcheck")
|
||||
virtualenv -p python3 venv --system-site-packages
|
||||
. venv/bin/activate
|
||||
setup_venv
|
||||
|
||||
pip install .
|
||||
pip install pyshark
|
||||
@@ -58,8 +69,7 @@ case "$JOB_TYPE" in
|
||||
# Print pylint version
|
||||
pip3 freeze | grep pylint
|
||||
|
||||
virtualenv -p python3 venv --system-site-packages
|
||||
. venv/bin/activate
|
||||
setup_venv
|
||||
|
||||
pip install .
|
||||
|
||||
@@ -77,15 +87,10 @@ case "$JOB_TYPE" in
|
||||
contrib/*.py
|
||||
;;
|
||||
"docs")
|
||||
virtualenv -p python3 venv --system-site-packages
|
||||
. venv/bin/activate
|
||||
setup_venv
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
# XXX: workaround for https://github.com/python-cmd2/cmd2/issues/1414
|
||||
# 2.4.3 was the last stable release not affected by this bug (OS#6776)
|
||||
pip install cmd2==2.4.3
|
||||
|
||||
rm -rf docs/_build
|
||||
make -C "docs" html latexpdf
|
||||
|
||||
|
||||
204
contrib/rcp/rcp_client.py
Executable file
204
contrib/rcp/rcp_client.py
Executable file
@@ -0,0 +1,204 @@
|
||||
#!/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 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 sys
|
||||
import websockets
|
||||
import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from pySim.log import PySimLogger
|
||||
from rcp_utils import CltConnHdlr, backtrace, pytype_to_type, load_ca_cert
|
||||
from pySim.transport import init_reader, argparse_add_reader_args, LinkBase
|
||||
|
||||
SERVER_TIMEOUT = 10
|
||||
|
||||
log = PySimLogger.get(Path(__file__).stem)
|
||||
option_parser = argparse.ArgumentParser(description='RCP Client',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
argparse_add_reader_args(option_parser)
|
||||
option_parser.add_argument("--verbose", help="Enable verbose logging",
|
||||
action='store_true', default=False)
|
||||
option_parser.add_argument("--uri", help="URI of the RCP-Server")
|
||||
option_parser.add_argument("--ca-cert", help="SSL/TLS CA-Certificate of the RCP-Server")
|
||||
|
||||
class RcpcCltConnHdlr(CltConnHdlr):
|
||||
def __init__(self, sl, *args, **kwargs):
|
||||
self.sl = sl
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def describe(self, suitable_for:dict) -> list:
|
||||
log.info("Requesting module descriptions from RCP Server ...")
|
||||
tx_json = {'rcpc_hello': {'suitable_for' : suitable_for}}
|
||||
rx_json = await self._transact(tx_json)
|
||||
module_descr = rx_json['rcpc_welcome']['module_descr']
|
||||
if not module_descr:
|
||||
raise ValueError("No RCP module available for this card")
|
||||
return module_descr
|
||||
|
||||
async def run(self, cmd:str, cmd_argv) -> int:
|
||||
log.info("Executing command with RCP Server ...")
|
||||
tx_json = {'rcpc_command': {'cmd' : cmd, 'cmd_argv' : cmd_argv}}
|
||||
while(True):
|
||||
rx_json = await self._transact(tx_json)
|
||||
tx_json = None
|
||||
if 'rcpc_instr' in rx_json:
|
||||
rcpc_instr = rx_json['rcpc_instr']
|
||||
if 'c_apdu' in rcpc_instr:
|
||||
c_apdu = rx_json['rcpc_instr']['c_apdu']
|
||||
data, sw = sl.send_apdu(c_apdu)
|
||||
tx_json = {'rcpc_result': {'r_apdu' : {'data': data.upper(), 'sw': sw.upper()}}}
|
||||
elif 'reset' in rcpc_instr:
|
||||
sl.reset_card()
|
||||
atr = sl.get_atr()
|
||||
tx_json = {'rcpc_result': {'atr' : atr.upper()}}
|
||||
elif 'print' in rcpc_instr:
|
||||
log.info(str(self) + " -- %s", rx_json['rcpc_instr']['print'])
|
||||
tx_json = {'rcpc_result': {'empty' : None}}
|
||||
elif 'rcpc_goodbye' in rx_json:
|
||||
rc = rx_json['rcpc_goodbye']
|
||||
log.info("Command execution done, rc: %d", rc)
|
||||
return rc
|
||||
|
||||
def check_if_user_needs_basic_help(argv):
|
||||
"""
|
||||
The '--uri' argument is the minimum requirement to connect to the RCP Server to retrieve the information about the
|
||||
dynamic commandline arguments. In case this argument is missing while '--help' or '-h' arguments are present. Then
|
||||
we will fall back to display only a basic help that contains only the static commandline arguments (see above).
|
||||
"""
|
||||
|
||||
if '--help' in argv or '-h' in argv:
|
||||
if '--uri' not in argv:
|
||||
option_parser.parse_args()
|
||||
sys.exit(1)
|
||||
|
||||
def parse_known_arguemnts(argv):
|
||||
"""
|
||||
Parse the commandline arguments we know so far. Ignore unknown arguments and filter out '--help' and '-h'
|
||||
arguments, in case those are present.
|
||||
"""
|
||||
|
||||
argv_filtered = deepcopy(argv)
|
||||
if '--help' in argv_filtered:
|
||||
argv_filtered.remove('--help')
|
||||
if '-h' in argv_filtered:
|
||||
argv_filtered.remove('-h')
|
||||
opts, unknown = option_parser.parse_known_args(argv_filtered)
|
||||
return opts
|
||||
|
||||
async def run_rcp_session(opts, sl, ssl_context) -> int:
|
||||
"""
|
||||
Connect to the RCP Server, retrieve the module description, use the module description to complete the commandline
|
||||
argument parser, execute the command that the user has selected.
|
||||
"""
|
||||
|
||||
# Request ATR from card
|
||||
card_atr = sl.get_atr().upper()
|
||||
log.info("Detected Card with ATR: %s" % card_atr)
|
||||
|
||||
# Connect to RCP server
|
||||
log.info("RCP Server URI: %s" % opts.uri)
|
||||
async with websockets.connect(opts.uri, ssl=ssl_context) as websocket:
|
||||
client = RcpcCltConnHdlr(sl, websocket, SERVER_TIMEOUT)
|
||||
|
||||
# Retrieve module description
|
||||
module_descrs = await client.describe({"atr" : card_atr})
|
||||
|
||||
# Complete the commandlie parser and set up a dict that we can use as filter
|
||||
# TODO: Maybe it makes sense to integrate this as a method into the RcpcCltConnHdlr class?
|
||||
option_subparsers = option_parser.add_subparsers(dest='command', help="RCP command to use", required=True)
|
||||
sys_argv_filter = {}
|
||||
for module_descr in module_descrs:
|
||||
cmd_descr = module_descr['cmd_descr']
|
||||
for cmd in cmd_descr:
|
||||
command_name = module_descr['name'] + "_" + cmd['name']
|
||||
option_parser_cmd = option_subparsers.add_parser(command_name, help=cmd['help'])
|
||||
sys_argv_filter[command_name] = []
|
||||
for arg in cmd['args']:
|
||||
arg['spec'] = pytype_to_type(arg['spec'])
|
||||
option_parser_cmd.add_argument(arg['name'], **arg['spec'])
|
||||
sys_argv_filter[command_name].append(arg['name'])
|
||||
|
||||
# Re-Parse commandline options with the completed commandline parser. In case commandline help is
|
||||
# requested. The program is able to display the full helpscreen and exists.
|
||||
opts = option_parser.parse_args()
|
||||
|
||||
# Filter the relevant command arguments from sys.argv
|
||||
cmd_argv = []
|
||||
next_is_value=False
|
||||
for arg in sys.argv:
|
||||
if arg in sys_argv_filter[opts.command]:
|
||||
cmd_argv.append(arg)
|
||||
next_is_value=True
|
||||
elif next_is_value is True:
|
||||
next_is_value=False
|
||||
cmd_argv.append(arg)
|
||||
|
||||
# Run the command and close the connection
|
||||
rc = await client.run(opts.command, cmd_argv)
|
||||
await client.close()
|
||||
return rc
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
# Setup logging
|
||||
PySimLogger.setup(print, {logging.WARN: "\033[33m", logging.DEBUG: "\033[90m"}, '--verbose' in sys.argv)
|
||||
|
||||
# Since parts of the commandline arguments are retrieved dynamically, we have to resolve a chicken-egg-problem.
|
||||
# We cannot call option_parser.parse_args() at the beginning, since we haven't received all information to
|
||||
# complete the option_parser yet. However in order to retrieve the arguments correctly we need to get the
|
||||
# URI and the parameters for the smartcard reader before we make the connection. The situation is even further
|
||||
# complicated in case the user requests commandline help.
|
||||
|
||||
# To resolve the problem we first check if the user needs basic help (no '--uri' parameter present). If this is the
|
||||
# case, the program will exit with a basic helpscreen.
|
||||
check_if_user_needs_basic_help(sys.argv)
|
||||
|
||||
# In all other cases we parse the arguments we know so far. In case the user requests commandline help, we will
|
||||
# ignore this request and continue. The full help is then displayed later when the option_parser is completed
|
||||
# afer we have requested the commandline argument descriptions from the RCP Server. (see below)
|
||||
opts = parse_known_arguemnts(sys.argv)
|
||||
|
||||
# Load SSL/TLS CA certificate from file
|
||||
if opts.ca_cert:
|
||||
ssl_context = load_ca_cert("RCP Server CA", opts.ca_cert)
|
||||
else:
|
||||
ssl_context = None
|
||||
|
||||
# Initialize card reader
|
||||
try:
|
||||
sl = init_reader(opts)
|
||||
sl.connect()
|
||||
except Exception as e:
|
||||
backtrace("Card reader initialization")
|
||||
sys.exit(1)
|
||||
|
||||
# Run the RCP session
|
||||
try:
|
||||
rc = asyncio.run(run_rcp_session(opts, sl, ssl_context))
|
||||
sys.exit(rc)
|
||||
except SystemExit as rc:
|
||||
sys.exit(rc)
|
||||
except:
|
||||
backtrace("RCP session")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
387
contrib/rcp/rcp_module_utils.py
Normal file
387
contrib/rcp/rcp_module_utils.py
Normal file
@@ -0,0 +1,387 @@
|
||||
#!/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 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 abc
|
||||
import os
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
import asyncio
|
||||
import websockets
|
||||
from argparse import Namespace
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from osmocom.utils import Hexstr, is_hexstr
|
||||
from pySim.utils import ResTuple
|
||||
from pySim.transport import LinkBase
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.log import PySimLogger
|
||||
from rcp_utils import SrvSyncConnHdlr, CltConnHdlr, backtrace, pytype_to_type, load_server_cert, load_ca_cert
|
||||
from rcp_utils import dict_from_key_value_pairs
|
||||
from websockets.sync.server import serve, ServerConnection
|
||||
|
||||
# Response timeout towards the RCP Server (includes RCP Client latency)
|
||||
RCP_SERVER_TIMEOUT = 30 # sec.
|
||||
|
||||
log = PySimLogger.get(Path(__file__).stem)
|
||||
|
||||
class RcpsSimLink(LinkBase):
|
||||
"""
|
||||
pySim: Transport Link for RCPM (Remote Card Procedure Module)
|
||||
This is a 'headless' transport link implementation that can only be used from an RCPM module. It merely serves as
|
||||
an adapter between the pySim transport API and the RCPM command server connection handler.
|
||||
"""
|
||||
|
||||
name = 'RCPM'
|
||||
|
||||
def __init__(self, conn_hdlr: SrvSyncConnHdlr, **kwargs):
|
||||
self.conn_hdlr = conn_hdlr
|
||||
self._atr = None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "rcpm:" + str(self.conn_hdlr)
|
||||
|
||||
def _send_apdu(self, apdu: Hexstr) -> ResTuple:
|
||||
tx_json = {'rcpc_instr': {'c_apdu' : apdu.upper()}}
|
||||
rx_json = self.conn_hdlr._transact(tx_json)
|
||||
data = rx_json['rcpc_result']['r_apdu']['data']
|
||||
sw = rx_json['rcpc_result']['r_apdu']['sw']
|
||||
return data, sw
|
||||
|
||||
def wait_for_card(self, timeout: Optional[int] = None, newcardonly: bool = False):
|
||||
# In this setting, we do not have/cannot to wait for a card since we are not the entity that handles the
|
||||
# direct connection to the card. When the procedure begins, we assume that the remote end already has set up
|
||||
# a connection to the card and made it ready to perform operations on it.
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
# In this setting, we do not have/cannot to connect because we are not the entity that handles the direct
|
||||
# connection to the card. The connection is established by the remote end.
|
||||
pass
|
||||
|
||||
def get_atr(self) -> Hexstr:
|
||||
return self._atr
|
||||
|
||||
def disconnect(self):
|
||||
# In this setting, we do not have/cannot disconnect because we are not the enitity that handles the direct
|
||||
# connection to the card. The disconnect is eventually done by the remote end when the procedure has finished.
|
||||
pass
|
||||
|
||||
def _reset_card(self):
|
||||
tx_json = {'rcpc_instr': {'reset' : None}}
|
||||
rx_json = self.conn_hdlr._transact(tx_json)
|
||||
self._atr = rx_json['rcpc_result']['atr']
|
||||
return 1
|
||||
|
||||
class RcpsCltConnHdlr(CltConnHdlr):
|
||||
"""
|
||||
The RCP Server client handler is used to connect to the RCP Server when RCP Module is started. The connection is
|
||||
kept alive until the RCP Module is terminated. This connection is used to exchange management data with the RCP
|
||||
Server.
|
||||
"""
|
||||
|
||||
def __init__(self, cmd_srv_addr: str, cmd_srv_port: int, module, *args, **kwargs):
|
||||
self.cmd_srv_addr = cmd_srv_addr
|
||||
self.cmd_srv_port = cmd_srv_port
|
||||
self.module = module
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def describe(self):
|
||||
"""
|
||||
Send a detailed description about this RCP Module to the RCP Server. This is also the initial message that
|
||||
the RCP Server expects when an RCP Module connects.
|
||||
"""
|
||||
|
||||
# The rules (dict) in suitable_for (array of dict) may contain hexstrings. Here we go through those rules
|
||||
# and convert those hexstrings to uppercase, since this is the standard we have set for the JSON messages.
|
||||
suitable_for = []
|
||||
for rule in self.module.suitable_for:
|
||||
rule_filtered = {}
|
||||
for k in rule:
|
||||
if is_hexstr(rule[k]):
|
||||
rule_filtered[k] = rule[k].upper()
|
||||
else:
|
||||
rule_filtered[k] = rule[k]
|
||||
suitable_for.append(rule_filtered)
|
||||
|
||||
# Publish RCP Module description on the RCP server
|
||||
tx_json = {'rcpm_hello':
|
||||
{'name' : self.module.name,
|
||||
'cmd_descr' : self.module.cmd_descr,
|
||||
'suitable_for' : suitable_for,
|
||||
'retrieve_keys' : {
|
||||
'euicc' : self.module.retrieve_euicc_keys,
|
||||
'uicc' : self.module.retrieve_uicc_keys
|
||||
},
|
||||
'addr' : self.cmd_srv_addr,
|
||||
'port' : self.cmd_srv_port
|
||||
}
|
||||
}
|
||||
rx_json = await self._transact(tx_json)
|
||||
if 'rcpm_welcome' not in rx_json:
|
||||
raise ValueError("description not accepted by RCP Server")
|
||||
|
||||
class RcpModule(abc.ABC):
|
||||
"""
|
||||
Base class to implement to derive a concrete RCPM module class
|
||||
"""
|
||||
|
||||
# Module name used to identify the module in logs and user output. This module name should be short and concise.
|
||||
name = "RCPM"
|
||||
|
||||
# Command description of this module. The command description consists of a short and concise command name, a
|
||||
# helpstring and an argument specification in the form of a python dict. This specificaton dict is directly
|
||||
# passed to agparse on the client side.
|
||||
#
|
||||
# Example:
|
||||
# [{"name" : "reset",
|
||||
# "help": "reset the card",
|
||||
# "args" : []},
|
||||
# {"name" : "read_binary",
|
||||
# "help": "read binary data from a transparent file.",
|
||||
# "args" : [ { "name" : "--fid",
|
||||
# "spec" : {"required" : True,
|
||||
# "help" : "File identifier to of the file to read",
|
||||
# "action" : "append"},
|
||||
# }
|
||||
# ]}
|
||||
# ]
|
||||
cmd_descr = []
|
||||
|
||||
# List with UICC (or eSIM) keys (columns) that the RCP Server shall retrieve before a command is executed.
|
||||
# Execution will not continue in case any of the requested keys is not found.
|
||||
# (see also: pySim.card_key_provider)
|
||||
#
|
||||
# Example: ['kic1', 'kid1', 'kik1']
|
||||
retrieve_uicc_keys = []
|
||||
|
||||
# Same as retrieve_uicc_keys (see above), but only applicable with eUICCs
|
||||
#
|
||||
# Example: ['isdr_kic1', 'isdr_kid1', 'isdr_kik1']
|
||||
retrieve_euicc_keys = []
|
||||
|
||||
# Card properties to determine if this module is suitable for a specific card type or card types. The RCP Server
|
||||
# will match those properties against user requests to determine which module provides useful services to the
|
||||
# user's card.
|
||||
#
|
||||
# Example: [{"atr" : "3b9f96803f87828031e073fe211f574543753130136502"}]
|
||||
suitable_for = []
|
||||
|
||||
# In addition the above, the derived class must implement command methods for each command that is defined in the
|
||||
# command description (see above). Each command method must begin with the prefix "cmd_" followed by the command
|
||||
# name used in the command description. A command method must have the form as shown in the example shown below.
|
||||
# Each method should return an integer value which will become the final return code of the RCP client program.
|
||||
#
|
||||
# Args:
|
||||
# hdlr: RcpModuleHdlr object, this object is provided by the RcpmCmdSrvConnHdlr object, which calls
|
||||
# the command method of the module. Through the RcpModuleHdlr object, the API user gets access
|
||||
# to special service methods (e.g. print) and other required properties (e.g. the SimCardCommands
|
||||
# objects, key material and others (see above).
|
||||
#
|
||||
# Example:
|
||||
# def cmd_reset(self, hdlr: RcpModuleHdlr) -> int: ...
|
||||
# def cmd_read_binary(self, hdlr: RcpModuleHdlr) -> int: ...
|
||||
|
||||
class RcpmCmdSrvConnHdlr(SrvSyncConnHdlr):
|
||||
"""
|
||||
The RCP Module command server connection handler is used to handle dedicated connections from the RCP Server. Those
|
||||
dedicated connections are technically transparent connections between the RCP Client and the RCP Module (this). The
|
||||
RCP Server merely acts as a proxy at that point.
|
||||
"""
|
||||
|
||||
def __init__(self, module: RcpModule, *args, **kwargs):
|
||||
SrvSyncConnHdlr.__init__(self, *args, *kwargs)
|
||||
self.module = module
|
||||
|
||||
def _parse_cmd_argv(self, cmd_suffix: str, cmd_argv: list[str]) -> Namespace:
|
||||
""" Parse (and validate) the received argument vector """
|
||||
# Use the cmd_descr of the module to create a (temporary) argument parser for the received argument vector
|
||||
cmd_parser = argparse.ArgumentParser()
|
||||
for cmd in self.module.cmd_descr:
|
||||
if cmd['name'] == cmd_suffix:
|
||||
args = deepcopy(cmd['args'])
|
||||
for arg in args:
|
||||
arg['spec'] = pytype_to_type(arg['spec'])
|
||||
cmd_parser.add_argument(arg['name'], **arg['spec'])
|
||||
|
||||
# Parse the arguments and return the parsed Namespace object.
|
||||
try:
|
||||
return cmd_parser.parse_args(cmd_argv)
|
||||
except SystemExit:
|
||||
raise ValueError("unable to parse arguments: %s", str(cmd_argv), )
|
||||
|
||||
def print(self, message: str):
|
||||
""" Print a message on the client side """
|
||||
log.info(str(self) + " -- %s" % message)
|
||||
tx_json = {'rcpc_instr': {'print' : message}}
|
||||
rx_json = self._transact(tx_json)
|
||||
if rx_json != {'rcpc_result': {'empty' : None}}:
|
||||
raise ValueError("unexpected response from RCP Client: %s", rx_json)
|
||||
|
||||
def procedure(self):
|
||||
""" Receive and process a command from the RCP Client (via RCP Server) """
|
||||
|
||||
# Receive the command request
|
||||
rx_json = self._recv()
|
||||
cmd = rx_json['rcpc_command']['cmd']
|
||||
cmd_argv = rx_json['rcpc_command']['cmd_argv']
|
||||
keys = rx_json['rcpc_command']['keys']
|
||||
keys_uicc = dict_from_key_value_pairs(keys['uicc'], keylabel='key', valuelabel='value')
|
||||
keys_euicc = dict_from_key_value_pairs(keys['euicc'], keylabel='key', valuelabel='value')
|
||||
|
||||
log.info(str(self) + " -- executing command: %s %s", cmd, " ".join(cmd_argv))
|
||||
|
||||
try:
|
||||
# Make sure the command actually addresses this module
|
||||
cmd_prefix = self.module.name + "_"
|
||||
if not cmd.startswith(cmd_prefix):
|
||||
raise ValueError("invalid command: %s" % cmd)
|
||||
|
||||
# Make sure the module actually provides a command method for the requested command
|
||||
cmd_suffix = cmd[len(cmd_prefix):]
|
||||
cmd_method = "cmd_" + cmd_suffix
|
||||
if not hasattr(self.module, cmd_method):
|
||||
raise ValueError("missing command method: %s" % cmd_method)
|
||||
|
||||
# Parse and validate command arguments
|
||||
cmd_args = self._parse_cmd_argv(cmd_suffix, cmd_argv)
|
||||
|
||||
# TODO: Perform a proper setup, similar to the one we have in pySim-shell, so that we have proper
|
||||
# runtime states and full access to the pySim API
|
||||
self.scc = SimCardCommands(transport=RcpsSimLink(self))
|
||||
self.scc.cla_byte = "00"
|
||||
self.scc.sel_ctrl = "0004"
|
||||
|
||||
# Hand over control to the command method provided by the specific module implementation
|
||||
try:
|
||||
rcp_module_hdlr = RcpModuleHdlr(self, cmd_args, keys_uicc, keys_euicc)
|
||||
rc = getattr(self.module, cmd_method)(rcp_module_hdlr)
|
||||
except Exception as e:
|
||||
backtrace("command method")
|
||||
rc = 1 # general error
|
||||
|
||||
except Exception as e:
|
||||
backtrace("command parsing")
|
||||
rc = 126 # cannot execute
|
||||
|
||||
# The prodedure is done, send "goodbye" message
|
||||
log.info(str(self) + " -- command execution done, rc: %d" % rc)
|
||||
tx_json = {'rcpc_goodbye': rc}
|
||||
self._send(tx_json)
|
||||
|
||||
class RcpModuleHdlr():
|
||||
"""
|
||||
RCP Module handler class. This class is used by the RcpmCmdSrvConnHdlr to create the handler RcpModuleHdlr object
|
||||
(hdlr), which is is passed to the command method. The RcpModuleHdlr gives the API user access to resources he can
|
||||
use carry out the command.
|
||||
"""
|
||||
|
||||
# The scc property contains the SimCardCommands object may be used to send APDUs, retrieve the ATR, or even more
|
||||
# complex tasks like selecting a file (see also pysim.commands)
|
||||
scc = None
|
||||
|
||||
# The cmd_args property contains the parsed command arguments which were passed by the end-user to the RCP Client.
|
||||
# The arguments are already parsed and validated against the cmd_dscr property of the RcpModule. The arguments are
|
||||
# in the form of a Namespace object and can be accessed like any argparse output. However, since the arguments
|
||||
# contain user input, some caution is required.
|
||||
cmd_args = None
|
||||
|
||||
# In case the retrieve_uicc_keys property of the RcpModule is used retrieve UICC key material, this property will
|
||||
# contain the key material in the form of a dictionary. The format is similar to the return value of
|
||||
# card_key_provider_get() (see also pySim.card_key_provider)
|
||||
keys_uicc = {}
|
||||
|
||||
# Same as self.keys_uicc, but contains eUICC related key material in case requested using retrieve_uicc_keys
|
||||
keys_euicc = {}
|
||||
|
||||
def __init__(self, hdlr: RcpmCmdSrvConnHdlr, cmd_args: Namespace, keys_uicc: dict, keys_euicc: dict):
|
||||
# The command method (API user) must not access the related RcpmCmdSrvConnHdlr (see below) directly. Only
|
||||
# the resources below may be accessed.
|
||||
self.__hdlr = hdlr
|
||||
|
||||
# Assign properties intended to be used by the command method (API user)
|
||||
self.scc = self.__hdlr.scc
|
||||
self.cmd_args = cmd_args
|
||||
self.keys_uicc = keys_uicc
|
||||
self.keys_euicc = keys_euicc
|
||||
|
||||
def print(self, message: str):
|
||||
""" Print a message on the client side """
|
||||
self.__hdlr.print(message)
|
||||
|
||||
def rcpm_setup_argparse(description: str):
|
||||
"""Create argument parser and add the basic arguments all RCP Modules should have"""
|
||||
|
||||
option_parser = argparse.ArgumentParser(description='RCP Module: ' + description,
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
|
||||
option_parser.add_argument("--uri", help="URI of the RCP-Server", required=True)
|
||||
option_parser.add_argument("--rcps-ca-cert", help="SSL/TLS CA-Certificate of the RCP-Server", required=True)
|
||||
option_parser.add_argument("--rcpm-cmd-server-addr", help="Local Host/IP to bind RCP-Module-Command-Server to",
|
||||
required=True)
|
||||
option_parser.add_argument("--rcpm-cmd-server-port", help="Local TCP port to bind RCP-Module-Command-Server to",
|
||||
required=True, type=int)
|
||||
option_parser.add_argument("--rcpm-cmd-server-cert", help="SSL/TLS Certificate of the RCP-Module-Command-Server",
|
||||
required=True)
|
||||
return option_parser
|
||||
|
||||
def rcpm_run_module(opts: Namespace, module: RcpModule, *args, **kwargs):
|
||||
|
||||
PySimLogger.setup(print, {logging.WARN: "\033[33m", logging.DEBUG: "\033[90m"}, opts.verbose)
|
||||
log.info("RCP Module startup: %s", module.name)
|
||||
log.debug("Main process ID: %d", os.getpid())
|
||||
|
||||
# Load SSL/TLS certificates
|
||||
rcpm_cmd_ssl_context = load_server_cert("RCPM Command Server", opts.rcpm_cmd_server_cert)
|
||||
ssl_context = load_ca_cert("RCPM Server Client", opts.rcps_ca_cert)
|
||||
|
||||
# Start local RCP Client Command Server
|
||||
log.info("RCPC command server at: %s:%d" % (opts.rcpm_cmd_server_addr, opts.rcpm_cmd_server_port))
|
||||
def rcpm_cmd_conn_hdlr(websocket: ServerConnection):
|
||||
hdlr = RcpmCmdSrvConnHdlr(module(*args, *kwargs), websocket, RCP_SERVER_TIMEOUT)
|
||||
hdlr.procedure()
|
||||
hdlr.close()
|
||||
|
||||
server = serve(rcpm_cmd_conn_hdlr, opts.rcpm_cmd_server_addr, opts.rcpm_cmd_server_port, ssl=rcpm_cmd_ssl_context)
|
||||
def rcpm_cmd_server():
|
||||
log.debug("RCPC command server thread ID: %d", threading.get_native_id())
|
||||
server.serve_forever()
|
||||
rcpm_cmd_server_thread = threading.Thread(target = rcpm_cmd_server)
|
||||
rcpm_cmd_server_thread.start()
|
||||
|
||||
# Connect to RCP Server and publish module description
|
||||
async def rcps_client():
|
||||
async with websockets.connect(opts.uri, ping_timeout=10.0, ping_interval=1.0, ssl=ssl_context) as websocket:
|
||||
client = RcpsCltConnHdlr(opts.rcpm_cmd_server_addr, opts.rcpm_cmd_server_port, module, websocket,
|
||||
RCP_SERVER_TIMEOUT)
|
||||
await client.describe()
|
||||
await client.wait_close()
|
||||
try:
|
||||
asyncio.run(rcps_client())
|
||||
except Exception as e:
|
||||
backtrace("RCPS client")
|
||||
|
||||
# Shutdown
|
||||
server.shutdown()
|
||||
rcpm_cmd_server_thread.join()
|
||||
log.info("RCP Module shutdown: %s", module.name)
|
||||
361
contrib/rcp/rcp_server.py
Executable file
361
contrib/rcp/rcp_server.py
Executable file
@@ -0,0 +1,361 @@
|
||||
#!/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 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 os
|
||||
import sys
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from osmocom.utils import Hexstr
|
||||
from pySim.utils import ResTuple
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from pySim.log import PySimLogger
|
||||
from pySim.utils import dec_iccid
|
||||
import websockets
|
||||
from websockets.asyncio.server import serve, ServerConnection
|
||||
from rcp_utils import SrvConnHdlr, CltConnHdlr, JsonValidator
|
||||
from rcp_utils import load_json_schema, backtrace, pytype_to_type, load_server_cert, load_ca_cert
|
||||
from rcp_utils import key_value_pairs_from_dict
|
||||
from pySim.card_key_provider import argparse_add_card_key_provider_args, init_card_key_provider
|
||||
from pySim.card_key_provider import card_key_provider_get_field, card_key_provider_get
|
||||
|
||||
# TODO: Logging is fine, however it may also be a good idea to log some higher level events to some sort of journal.
|
||||
# We could use OpenObserve for that.
|
||||
|
||||
CLIENT_TIMEOUT = 10
|
||||
|
||||
log = PySimLogger.get(Path(__file__).stem)
|
||||
runtime_state = None
|
||||
option_parser = argparse.ArgumentParser(description='RCP Server',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
option_parser.add_argument("--verbose", help="Enable verbose logging",
|
||||
action='store_true', default=False)
|
||||
option_parser.add_argument("--rcpc-server-addr", help="Local Host/IP to bind RCP-Client-Server to",
|
||||
required=True)
|
||||
option_parser.add_argument("--rcpc-server-port", help="Local TCP port to bind RCP-Client-Server to",
|
||||
required=True, type=int)
|
||||
option_parser.add_argument("--rcpc-server-cert", help="SSL/TLS Certificate of the RCP-Client-Server",
|
||||
required=True)
|
||||
option_parser.add_argument("--rcpm-server-addr", help="Local Host/IP to bind RCP-Module-Server to",
|
||||
required=True)
|
||||
option_parser.add_argument("--rcpm-server-port", help="Local TCP port to bind RCP-Module-Server to",
|
||||
required=True, type=int)
|
||||
option_parser.add_argument("--rcpm-server-cert", help="SSL/TLS Certificate of the RCP-Module-Server",
|
||||
required=True)
|
||||
option_parser.add_argument("--rcpm-module-ca-cert", help="SSL/TLS CA-Certificate of the RCP-Module-Command-Server",
|
||||
required=True)
|
||||
argparse_add_card_key_provider_args(option_parser)
|
||||
|
||||
# TODO move those into the RuntimeState?
|
||||
rcpc_rx_schema = None
|
||||
rcpc_tx_schema = None
|
||||
rcpm_ca_ssl_contextssl_context = None
|
||||
|
||||
class ModuleRuntimeState:
|
||||
def __init__(self, websocket:ServerConnection, name:str, cmd_descr:list, suitable_for:list, retrieve_keys:dict,
|
||||
addr:str, port:int):
|
||||
self.name = name
|
||||
self.websocket = websocket
|
||||
|
||||
# Run the cmd_descr through argparse to catch malformed arguments early
|
||||
for cmd in cmd_descr:
|
||||
args = deepcopy(cmd['args'])
|
||||
cmd_parser = argparse.ArgumentParser()
|
||||
for arg in args:
|
||||
# TODO: wrap this into a try/catch block and log broken arguments?
|
||||
arg['spec'] = pytype_to_type(arg['spec'])
|
||||
cmd_parser.add_argument(arg['name'], **arg['spec'])
|
||||
|
||||
self.cmd_descr = cmd_descr
|
||||
self.suitable_for = suitable_for
|
||||
self.retrieve_keys = retrieve_keys
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
log.debug("new RCP module context created: '%s'", name)
|
||||
|
||||
def is_suitable(self, suitable_for:dict) -> bool:
|
||||
if suitable_for in self.suitable_for:
|
||||
return True
|
||||
return False
|
||||
|
||||
def describe(self) -> dict:
|
||||
return {'name': self.name,
|
||||
'cmd_descr': self.cmd_descr}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def __del__(self):
|
||||
log.debug("RCP module context destroyed: '%s'", self.name)
|
||||
|
||||
class RuntimeState:
|
||||
def __init__(self):
|
||||
self.module_runtime_states = []
|
||||
log.debug("new runtime context created.")
|
||||
|
||||
def __log_modules_available(self) -> str:
|
||||
if self.module_runtime_states:
|
||||
modules_str = ""
|
||||
for module in self.module_runtime_states:
|
||||
modules_str += "'" + str(module) + "', "
|
||||
return "RCP modules available: %s" % modules_str[:-2]
|
||||
else:
|
||||
return "RCP modules available: none"
|
||||
|
||||
def module_add(self, module: ModuleRuntimeState):
|
||||
self.module_runtime_states.append(module)
|
||||
log.info("new RCP module, %s", self.__log_modules_available())
|
||||
|
||||
def module_remove(self, websocket:ServerConnection):
|
||||
for module in self.module_runtime_states:
|
||||
if module.websocket == websocket:
|
||||
self.module_runtime_states.remove(module)
|
||||
log.info("RCP module removed, %s", self.__log_modules_available())
|
||||
return
|
||||
log.warning("cannot remove RCP module, no RCP module associated with RCPC connection: %s:%d, %s" %
|
||||
(*websocket.remote_address, self.__log_modules_available()))
|
||||
|
||||
def modules_find(self, suitable_for:dict) -> list[dict]:
|
||||
modules = []
|
||||
for module in self.module_runtime_states:
|
||||
if module.is_suitable(suitable_for):
|
||||
modules.append(module.describe())
|
||||
if modules:
|
||||
return modules
|
||||
# It is absolutely tolerable if no suitable RCP module can be found. If this is the case, the client should
|
||||
# display an empty help screen and exit normally.
|
||||
log.warning("no suitable RCP module found, %s", self.__log_modules_available())
|
||||
return []
|
||||
|
||||
def module_find(self, suitable_for:dict, cmd:str) -> ModuleRuntimeState:
|
||||
modules = self.modules_find(suitable_for)
|
||||
for m in modules:
|
||||
module_name = m['name']
|
||||
cmd_descr = m['cmd_descr']
|
||||
for c in cmd_descr:
|
||||
cmd_name = c['name']
|
||||
if module_name + "_" + cmd_name == cmd:
|
||||
break
|
||||
for module_runtime_state in self.module_runtime_states:
|
||||
if module_runtime_state.name == module_name:
|
||||
return module_runtime_state
|
||||
# Normally we should find the RCP module. When this method is called, we have already called modules_find
|
||||
# before because we had to return the command descriptions to the client. If we cannot find the RCP module
|
||||
# now, the module have been disconnected or the client somehow called a command that does not exist. In any
|
||||
# case, ending up here means we cannot continue.
|
||||
raise ValueError("RCP module not found for command: %s, ", cmd, self.__log_modules_available())
|
||||
|
||||
class RcpmCltConnHdlr(CltConnHdlr):
|
||||
"""
|
||||
The RCP Module client connection handler is the dedicated client that is used by the RCP Client connection handler
|
||||
to handle the dedicated connection towards the RCP Module (see below)
|
||||
"""
|
||||
|
||||
class RcpcSrvConnHdlr(SrvConnHdlr):
|
||||
"""
|
||||
The RCP Client connection handler takes care of the handling of client requests. Througout the lifetime of a
|
||||
connection, the client will request a description of the available commands and then request the execution of a
|
||||
procedure. To execute the procedure, the handler will make a dedicated connection to the RCP Module and then
|
||||
transparently pass the messages from the RCP Client to the RCP Module and vice versa.
|
||||
"""
|
||||
|
||||
async def describe(self):
|
||||
"""
|
||||
Collect the command/argument description of suitable modules and forward that definition to the RCP client. The
|
||||
RCP client will then build an argument parser (commmandlien help, argument validation) from this information.
|
||||
"""
|
||||
rx_json = await self._recv()
|
||||
self.suitable_for = rx_json['rcpc_hello']['suitable_for']
|
||||
modules = runtime_state.modules_find(self.suitable_for)
|
||||
tx_json = {'rcpc_welcome':
|
||||
{'module_descr' : modules}
|
||||
}
|
||||
await self._send(tx_json)
|
||||
|
||||
async def _transact_apdu(self, apdu: Hexstr) -> ResTuple:
|
||||
"""Private low level method to exchange an APDU"""
|
||||
tx_json = {'rcpc_instr': {'c_apdu' : apdu.upper()}}
|
||||
rx_json = await self._transact(tx_json)
|
||||
data = rx_json['rcpc_result']['r_apdu']['data']
|
||||
sw = rx_json['rcpc_result']['r_apdu']['sw']
|
||||
return data, sw
|
||||
|
||||
async def _reset(self) -> Hexstr:
|
||||
"""Private low level method to reset the UICC/eUICC"""
|
||||
tx_json = {'rcpc_instr': {'reset' : None}}
|
||||
rx_json = await self._transact(tx_json)
|
||||
return rx_json['rcpc_result']['atr']
|
||||
|
||||
async def _read_iccid(self) -> Hexstr:
|
||||
"""Private low level method to read the EID from an UICC (or eSIM)"""
|
||||
data, sw = await self._transact_apdu("00A40000022FE200")
|
||||
if sw != "9000":
|
||||
raise ValueError("Unable to select EF.ICCID, sw: %s, " % sw)
|
||||
data, sw = await self._transact_apdu("00B000000A")
|
||||
if sw != "9000":
|
||||
raise ValueError("Unable to read EF.ICCID, sw: %s, " % sw)
|
||||
return dec_iccid(data)
|
||||
|
||||
async def _read_eid(self) -> Hexstr:
|
||||
"""Private low level method to read the EID from an eUICC"""
|
||||
data, sw = await self._transact_apdu("00A4040410A0000005591010FFFFFFFF890000010000")
|
||||
if sw != "9000":
|
||||
raise ValueError("Unable to select ISD-R, sw: %s, " % sw)
|
||||
data, sw = await self._transact_apdu("80E2910006BF3E035C015A00")
|
||||
if sw != "9000":
|
||||
raise ValueError("Unable to retrieve EID, sw: %s, " % sw)
|
||||
return data[10:]
|
||||
|
||||
async def print(self, message: str):
|
||||
""" Print a message on the client side """
|
||||
tx_json = {'rcpc_instr': {'print' : message}}
|
||||
rx_json = await self._transact(tx_json)
|
||||
if rx_json != {'rcpc_result': {'empty' : None}}:
|
||||
raise ValueError("unexpected response from RCP Client: %s", rx_json)
|
||||
|
||||
async def procedure(self):
|
||||
"""
|
||||
Receive a command from the client, pick a matching module, make a decdicated connection to that module and
|
||||
forward instruction/response messages between RCP Client and RCP Module until the procedure is done.
|
||||
"""
|
||||
|
||||
# Expect a command from the client
|
||||
rx_json = await self._recv()
|
||||
if rx_json is None:
|
||||
log.debug(str(self) + " -- RCP client has closed the connection, no procedure executed")
|
||||
return
|
||||
command = rx_json['rcpc_command']
|
||||
|
||||
# Pick the matching RCP Module
|
||||
module = runtime_state.module_find(self.suitable_for, command['cmd'])
|
||||
|
||||
# Retrieve keys (if module requires them)
|
||||
keys = {}
|
||||
if module.retrieve_keys['uicc']:
|
||||
iccid = await self._read_iccid()
|
||||
keys_uicc = card_key_provider_get(module.retrieve_keys['uicc'], 'ICCID', iccid)
|
||||
keys['uicc'] = key_value_pairs_from_dict(keys_uicc, keylabel='key', valuelabel='value')
|
||||
else:
|
||||
keys['uicc'] = []
|
||||
if module.retrieve_keys['euicc']:
|
||||
eid = await self._read_eid()
|
||||
keys_euicc = card_key_provider_get(module.retrieve_keys['euicc'], 'EID', eid)
|
||||
keys['euicc'] = key_value_pairs_from_dict(keys_euicc, keylabel='key', valuelabel='value')
|
||||
else:
|
||||
keys['euicc'] = []
|
||||
command['keys'] = keys
|
||||
|
||||
# Resetting card to ensure the card is in a defined state
|
||||
await self._reset()
|
||||
|
||||
# Transparently forward messages between RCP Client and RCP Module
|
||||
module_uri = "wss://%s:%d" % (module.addr, module.port)
|
||||
log.info(str(self) + " -- executing procedure for command \"%s\" on module \"%s\" at: %s" %
|
||||
(command['cmd'], module.name, module_uri))
|
||||
async with websockets.connect(module_uri, ssl=rcpm_ca_ssl_context) as websocket:
|
||||
module_client = RcpmCltConnHdlr(websocket, CLIENT_TIMEOUT)
|
||||
rx_json = {'rcpc_command' : command}
|
||||
while(True):
|
||||
module_rx_json = await module_client._transact(rx_json)
|
||||
await self._send(module_rx_json)
|
||||
if 'rcpc_goodbye' in module_rx_json:
|
||||
log.info(str(self) + " -- command execution done, rc: %d" % module_rx_json['rcpc_goodbye'])
|
||||
break
|
||||
rx_json = await self._recv()
|
||||
await module_client.close()
|
||||
|
||||
class RcpmSrvConnHdlr(SrvConnHdlr):
|
||||
"""
|
||||
The RCP Module connection handler is responsible to handle connect and disconnect events of RCP Modules. This
|
||||
connection between the RCP Module and the RCP Server is used for management purposes only.
|
||||
"""
|
||||
|
||||
async def describe(self):
|
||||
"""
|
||||
Receive the module description from an RCP Module. This description will be stored in an internal list until
|
||||
the module is disconnected from the server.
|
||||
"""
|
||||
rx_json = await self._recv()
|
||||
runtime_state.module_add(module = ModuleRuntimeState(self.websocket, **rx_json['rcpm_hello']))
|
||||
tx_json = {'rcpm_welcome': {}}
|
||||
await self._send(tx_json)
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Remove RCPM from internal list when the connection is closed (and the handler is deleted)
|
||||
"""
|
||||
runtime_state.module_remove(self.websocket)
|
||||
super().__del__()
|
||||
|
||||
async def rcpc_conn_hdlr(websocket: ServerConnection):
|
||||
# TODO: Implement some sort of rate limit to protect against DoS. We may count the requests for each requesting
|
||||
# IP address and reject the connection once a certain threshold is reached. (we plan to use the CardKeyProvider
|
||||
# together with a database)
|
||||
try:
|
||||
json_validator = JsonValidator(rcpc_rx_schema, rcpc_tx_schema)
|
||||
hdlr = RcpcSrvConnHdlr(websocket, CLIENT_TIMEOUT, json_validator)
|
||||
await hdlr.describe()
|
||||
await hdlr.procedure()
|
||||
await hdlr.close()
|
||||
except:
|
||||
backtrace("RCPC connection handler")
|
||||
|
||||
async def rcpm_conn_hdlr(websocket: ServerConnection):
|
||||
try:
|
||||
hdlr = RcpmSrvConnHdlr(websocket, CLIENT_TIMEOUT)
|
||||
await hdlr.describe()
|
||||
await hdlr.close()
|
||||
except:
|
||||
backtrace("RCPM connection handler")
|
||||
|
||||
if __name__ == '__main__':
|
||||
opts = option_parser.parse_args()
|
||||
|
||||
PySimLogger.setup(print, {logging.WARN: "\033[33m", logging.DEBUG: "\033[90m"}, opts.verbose)
|
||||
runtime_state = RuntimeState()
|
||||
|
||||
# TODO: Modularize the JSON schemas. We already repeat ourselves with multiple definitions of the ATR fields.
|
||||
rcpc_rx_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcpc_rx_schema.json"))
|
||||
rcpc_tx_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcpc_tx_schema.json"))
|
||||
|
||||
# Load SSL/TLS certificates
|
||||
rcpc_ssl_context = load_server_cert("RCP Client Server", opts.rcpc_server_cert)
|
||||
rcpm_ssl_context = load_server_cert("RCP Module Server", opts.rcpm_server_cert)
|
||||
rcpm_ca_ssl_context = load_ca_cert("RCP Module Command Server Client", opts.rcpm_module_ca_cert)
|
||||
|
||||
# Init card key provider for automatic card key retrieval
|
||||
init_card_key_provider(opts)
|
||||
|
||||
# Start RCP server
|
||||
async def rcp_server():
|
||||
log.info("RCP Client Server at: %s:%d" % (opts.rcpc_server_addr, opts.rcpc_server_port))
|
||||
log.info("RCP Module server at: %s:%d" % (opts.rcpm_server_addr, opts.rcpm_server_port))
|
||||
async with serve(rcpc_conn_hdlr, opts.rcpc_server_addr, opts.rcpc_server_port, ssl=rcpc_ssl_context), \
|
||||
serve(rcpm_conn_hdlr, opts.rcpm_server_addr, opts.rcpm_server_port, ssl=rcpm_ssl_context):
|
||||
await asyncio.get_running_loop().create_future()
|
||||
try:
|
||||
asyncio.run(rcp_server())
|
||||
except SystemExit:
|
||||
pass
|
||||
except:
|
||||
backtrace("RCP Server")
|
||||
sys.exit(1)
|
||||
|
||||
254
contrib/rcp/rcp_utils.py
Normal file
254
contrib/rcp/rcp_utils.py
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/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 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 sys
|
||||
import ssl
|
||||
import json
|
||||
import abc
|
||||
import asyncio
|
||||
import websockets
|
||||
import traceback
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from websockets.asyncio.server import ServerConnection
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
from pathlib import Path
|
||||
from jsonschema import validate
|
||||
from pySim.log import PySimLogger
|
||||
from ssl import SSLContext
|
||||
|
||||
log = PySimLogger.get(Path(__file__).stem)
|
||||
|
||||
# TODO: Might be helpful for others as well, move this to pySim.utils?
|
||||
def backtrace(what: str):
|
||||
log.error("%s failed with an exception:", what)
|
||||
log.error("---------------------8<---------------------")
|
||||
traceback_lines = traceback.format_exc()
|
||||
for line in traceback_lines.split("\n"):
|
||||
if line:
|
||||
log.error(line)
|
||||
log.error("---------------------8<---------------------")
|
||||
|
||||
# TODO: Might be helpful for others as well, move this to pySim.utils?
|
||||
def key_value_pairs_from_dict(keys: dict, keylabel: str='key', valuelabel: str='value') -> list:
|
||||
key_list = []
|
||||
for key in keys:
|
||||
key_list.append({keylabel : key, valuelabel : keys[key]})
|
||||
return key_list
|
||||
|
||||
# TODO: Might be helpful for others as well, move this to pySim.utils?
|
||||
def dict_from_key_value_pairs(keys: list, keylabel: str='key', valuelabel: str='value') -> dict:
|
||||
key_dict = {}
|
||||
for key in keys:
|
||||
key_dict[key[keylabel]] = key[valuelabel]
|
||||
return key_dict
|
||||
|
||||
def pytype_to_type(dict_in: dict) -> dict:
|
||||
"""
|
||||
There is no way to properly express python types in JSON. This function can be used to replace
|
||||
each ocurrence of "pytype", with "type", where the string type name is replaced with an actual
|
||||
python type.
|
||||
"""
|
||||
dict_out = deepcopy(dict_in)
|
||||
if dict_out.get('pytype'):
|
||||
if dict_out['pytype'] == "str":
|
||||
dict_out.pop('pytype')
|
||||
dict_out['type'] = str
|
||||
elif dict_out['pytype'] == "int":
|
||||
dict_out.pop('pytype')
|
||||
dict_out['type'] = int
|
||||
else:
|
||||
raise ValueError("invalid type in command argument specification: %s" % arg['spec']['type'])
|
||||
return dict_out
|
||||
|
||||
def load_json_schema(filename: str) -> dict:
|
||||
"""Load a JSON schema from file"""
|
||||
log.info("loading JSON schema: %s", filename)
|
||||
try:
|
||||
with open(filename) as schema_file:
|
||||
return json.load(schema_file)
|
||||
except Exception as e:
|
||||
backtrace("JSON schema load")
|
||||
sys.exit(1)
|
||||
|
||||
def load_server_cert(what: str, filename: str) -> SSLContext:
|
||||
"""Load an SSL/TLS server certificate"""
|
||||
log.info("loading SSL/TLS server certificate (%s): %s", what, filename)
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(filename)
|
||||
return ssl_context
|
||||
|
||||
def load_ca_cert(what: str, filename: str) -> SSLContext:
|
||||
"""Load an SSL/TLS CA certificate"""
|
||||
log.info("loading SSL/TLS CA certificate (%s): %s", what, filename)
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ssl_context.load_verify_locations(filename)
|
||||
return ssl_context
|
||||
|
||||
class JsonValidator():
|
||||
"""
|
||||
JSON validator class, can be passed to any ConnHdlr object to automatically validate the JSON messages which are
|
||||
sent and and received.
|
||||
"""
|
||||
|
||||
def __init__(self, rx_schema: dict, tx_schema: dict = None):
|
||||
self.rx_schema = rx_schema
|
||||
if tx_schema:
|
||||
self.tx_schema = tx_schema
|
||||
else:
|
||||
self.tx_schema = None
|
||||
|
||||
def valid_rx_json(self, rx_json: dict):
|
||||
validate(instance = rx_json, schema = self.rx_schema)
|
||||
|
||||
def valid_tx_json(self, tx_json: dict):
|
||||
if self.tx_schema:
|
||||
# We intentionally do not prevent the sending of an invalid JSON message. It is the responsibility of the
|
||||
# receiving end to detect an invalid message and react accordingly. The purpose of this validation is to
|
||||
# make developers/users aware of the problem.
|
||||
try:
|
||||
validate(instance = tx_json, schema = self.tx_schema)
|
||||
except Exception as e:
|
||||
backtrace("JSON schema validation (TX)")
|
||||
|
||||
class ConnHdlr(abc.ABC):
|
||||
"""Base class that can be used to create a connection handler"""
|
||||
|
||||
def __init__(self, websocket: ServerConnection | ClientConnection, timeout: int,
|
||||
json_validator: JsonValidator = None):
|
||||
self.websocket = websocket
|
||||
self.timeout = timeout
|
||||
self.json_validator = json_validator
|
||||
log.debug(str(self) + " -- new handler, timeout: %d sec.", self.timeout)
|
||||
|
||||
def _log_recv_peer(self, rx_json_str: str):
|
||||
peer = "%s:%d<-%s:%d" % (self.websocket.local_address[0],
|
||||
self.websocket.local_address[1],
|
||||
self.websocket.remote_address[0],
|
||||
self.websocket.remote_address[1])
|
||||
log.debug(str(self) + " -- RX(%s): %s", peer, rx_json_str)
|
||||
|
||||
def _log_send_peer(self, tx_json_str: str):
|
||||
peer = "%s:%d->%s:%d" % (self.websocket.local_address[0],
|
||||
self.websocket.local_address[1],
|
||||
self.websocket.remote_address[0],
|
||||
self.websocket.remote_address[1])
|
||||
log.debug(str(self) + " -- TX(%s): %s", peer, tx_json_str)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "%s(%d)" % (type(self).__name__, id(self))
|
||||
|
||||
def __del__(self):
|
||||
log.debug(str(self) + " -- closed handler")
|
||||
|
||||
class SrvConnHdlr(ConnHdlr):
|
||||
"""Base class that can be used to create a connection handler for a server"""
|
||||
|
||||
async def _recv(self) -> dict:
|
||||
"""Receive JSON message from client"""
|
||||
async with asyncio.timeout(self.timeout):
|
||||
try:
|
||||
rx_json_str = await self.websocket.recv()
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
log.debug(str(self) + " -- no data received, connection is closed")
|
||||
return None
|
||||
self._log_recv_peer(rx_json_str)
|
||||
rx_json = json.loads(rx_json_str)
|
||||
if self.json_validator:
|
||||
self.json_validator.valid_rx_json(rx_json)
|
||||
return rx_json
|
||||
|
||||
async def _send(self, tx_json: dict):
|
||||
"""Send JSON message to client"""
|
||||
if self.json_validator:
|
||||
self.json_validator.valid_tx_json(tx_json)
|
||||
tx_json_str = json.dumps(tx_json)
|
||||
self._log_send_peer(tx_json_str)
|
||||
await self.websocket.send(tx_json_str)
|
||||
|
||||
async def _transact(self, tx_json: dict) -> dict:
|
||||
"""Exchange JSON message with client"""
|
||||
await self._send(tx_json)
|
||||
return await self._recv()
|
||||
|
||||
async def close(self):
|
||||
"""Wait for a connecion to close normally"""
|
||||
await self.websocket.wait_closed()
|
||||
log.debug(str(self) + " -- closed connection")
|
||||
|
||||
class SrvSyncConnHdlr(ConnHdlr):
|
||||
"""Base class that can be used to create a synchronous connection handler for a server"""
|
||||
|
||||
def _recv(self) -> dict:
|
||||
"""Receive JSON message from client"""
|
||||
# TODO: we do not have a timeout here (the self.timeout is currently useless). Check if we can do something
|
||||
# about this or if we have to implement some watchdog functionality elsewhere.
|
||||
rx_json_str = self.websocket.recv()
|
||||
self._log_recv_peer(rx_json_str)
|
||||
rx_json = json.loads(rx_json_str)
|
||||
if self.json_validator:
|
||||
self.json_validator.valid_rx_json(rx_json)
|
||||
return rx_json
|
||||
|
||||
def _send(self, tx_json: dict):
|
||||
"""Send JSON message to client"""
|
||||
if self.json_validator:
|
||||
self.json_validator.valid_tx_json(tx_json)
|
||||
tx_json_str = json.dumps(tx_json)
|
||||
self._log_send_peer(tx_json_str)
|
||||
self.websocket.send(tx_json_str)
|
||||
|
||||
def _transact(self, tx_json: dict) -> dict:
|
||||
"""Exchange JSON message with client"""
|
||||
self._send(tx_json)
|
||||
return self._recv()
|
||||
|
||||
def close(self):
|
||||
"""Close connection normally"""
|
||||
self.websocket.close()
|
||||
log.debug(str(self) + " -- closed connection")
|
||||
|
||||
class CltConnHdlr(ConnHdlr):
|
||||
"""Base class that can be used to create a connection handler for a client"""
|
||||
|
||||
async def _transact(self, tx_json: dict) -> dict:
|
||||
"""Exchange JSON message with server"""
|
||||
if self.json_validator:
|
||||
self.json_validator.valid_tx_json(tx_json)
|
||||
tx_json_str = json.dumps(tx_json)
|
||||
self._log_send_peer(tx_json_str)
|
||||
async with asyncio.timeout(self.timeout):
|
||||
await self.websocket.send(tx_json_str)
|
||||
rx_json_str = await self.websocket.recv()
|
||||
self._log_recv_peer(rx_json_str)
|
||||
rx_json = json.loads(rx_json_str);
|
||||
if self.json_validator:
|
||||
self.json_validator.valid_rx_json(rx_json)
|
||||
return rx_json
|
||||
|
||||
async def close(self):
|
||||
"""Close connection normally"""
|
||||
await self.websocket.close()
|
||||
log.debug(str(self) + " -- closed connection")
|
||||
|
||||
async def wait_close(self):
|
||||
"""Wait for a connecion to close normally"""
|
||||
await self.websocket.wait_closed()
|
||||
log.debug(str(self) + " -- closed connection")
|
||||
69
contrib/rcp/rcpc_rx_schema.json
Normal file
69
contrib/rcp/rcpc_rx_schema.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "RCP Client RX",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rcpc_hello": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suitable_for": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"atr": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9,A-F]{0,66}$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rcpc_command": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9,A-Z,a-z,_]{0,40}$"
|
||||
},
|
||||
"cmd_argv": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^.{0,512}$"
|
||||
},
|
||||
"maxItems": 255
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rcpc_result": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"r_apdu": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9,A-F]{0,512}$"
|
||||
},
|
||||
"sw": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9,A-F]{0,4}$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"atr": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9,A-F]{0,66}$"
|
||||
},
|
||||
"empty": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
89
contrib/rcp/rcpc_tx_schema.json
Normal file
89
contrib/rcp/rcpc_tx_schema.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "RCP Client TX",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rcpc_welcome": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_descr": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"cmd_descr": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"help": {
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"required" : {
|
||||
"type": "boolean"
|
||||
},
|
||||
"help": {
|
||||
"type": "string"
|
||||
},
|
||||
"action": {
|
||||
"type": "string"
|
||||
},
|
||||
"pytype": {
|
||||
"type": "string"
|
||||
},
|
||||
"default" : {
|
||||
"type": ["string", "integer"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rcpc_instr": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"print": {
|
||||
"type": "string"
|
||||
},
|
||||
"reset": {
|
||||
"type": "null"
|
||||
},
|
||||
"c_apdu": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9,A-F]{0,512}$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rcpc_goodbye": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
20
contrib/rcp/usage_example/certs/example_ssl_rcp_ca_cert.crt
Normal file
20
contrib/rcp/usage_example/certs/example_ssl_rcp_ca_cert.crt
Normal file
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDSzCCAjOgAwIBAgIUEv1f0yjVtkr+RNYLItZ33eTJwHMwDQYJKoZIhvcNAQEL
|
||||
BQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMjYwNDI5MTIwOTM0WhcNMzYw
|
||||
NDI2MTIwOTM0WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcN
|
||||
AQEBBQADggEPADCCAQoCggEBANXdkSyQlDzuo2cJmnBmFiZpc0V9tYBcNkpZd3Ac
|
||||
R0WljazKKgXDWNmOcSO7891bi+1HZzz+nDfV0mJY776ScGkTqF43Hzpg9eZakMAx
|
||||
yC24mT4h+uyRcPWZrBwaQhpiQrvZy4MRyuUB+BEgBSmhoDiuXP44kWiuEJHuzpOq
|
||||
X6Q2dW8RIeQPDGGK6XPZIQLqx+krxkaqphd/vHgT1/yd7Ol5xxMc4x2UuPaVCj0D
|
||||
OzslFsbb0Zu77ffCtHOVVnzSCzeEGGx1MPQm6hDVW+KUXXTwke1K55fmFZhu0gKO
|
||||
HYSEjgPj6X8muDb+GvOAQX3fHmS6KvFS4fwWd2InZ3v2f3cCAwEAAaOBkDCBjTAM
|
||||
BgNVHRMEBTADAQH/MB0GA1UdDgQWBBS6zY4Dd0pJFrvWLmyjn0vDTFqVqzBRBgNV
|
||||
HSMESjBIgBS6zY4Dd0pJFrvWLmyjn0vDTFqVq6EapBgwFjEUMBIGA1UEAwwLRWFz
|
||||
eS1SU0EgQ0GCFBL9X9Mo1bZK/kTWCyLWd93kycBzMAsGA1UdDwQEAwIBBjANBgkq
|
||||
hkiG9w0BAQsFAAOCAQEAGJUXlbnVhh+xL+pyTyjwtd8nxhUcHzYZl+OT0bkGY9zT
|
||||
S3NjHkKBbdnEftuYDYqp0uBuGFQ1WIOKiM3rp4IePKe84lSivZMVh9ObtNalcEQr
|
||||
sqxBziNOMJM2mh5V2NdxiK2E1gCZ959wOQ8yzM6gGC+wW8w4zwULhv4JimQDjk+G
|
||||
kAdiGL7+WAxrNWUulvm8khFt2nOlucJg4IAYVt2SI1AFMt/YSXoA4wMwM9QcHGj0
|
||||
1A069IxX93WVhUpIL1Avwz+KJK0BPY6SM8LYUy6V50Hojp76BB7VD6SxQrSoceUo
|
||||
6cRNDtCmofOlltfeUJLr1mI4S2tM50bQVsHD92EJBA==
|
||||
-----END CERTIFICATE-----
|
||||
115
contrib/rcp/usage_example/certs/example_ssl_rcpc_rcps_cert.pem
Normal file
115
contrib/rcp/usage_example/certs/example_ssl_rcpc_rcps_cert.pem
Normal file
@@ -0,0 +1,115 @@
|
||||
Certificate:
|
||||
Data:
|
||||
Version: 3 (0x2)
|
||||
Serial Number:
|
||||
42:38:a5:6f:70:53:40:e4:a4:1a:2c:0f:fc:81:13:42
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Issuer: CN=Easy-RSA CA
|
||||
Validity
|
||||
Not Before: Apr 29 12:09:35 2026 GMT
|
||||
Not After : Aug 1 12:09:35 2028 GMT
|
||||
Subject: CN=example_ssl_rcpc_rcps_cert
|
||||
Subject Public Key Info:
|
||||
Public Key Algorithm: rsaEncryption
|
||||
Public-Key: (2048 bit)
|
||||
Modulus:
|
||||
00:ae:0f:e1:ee:fc:f6:db:75:45:c0:f4:49:72:46:
|
||||
3d:e3:db:0c:c4:34:d2:9e:49:d4:86:4f:19:0d:55:
|
||||
70:50:81:e4:e6:64:56:a8:58:e8:e6:54:0a:16:bc:
|
||||
f4:4b:84:cd:1d:b9:2e:ed:62:b6:cd:62:35:8b:81:
|
||||
18:ab:ff:63:f5:c1:dc:16:3e:a8:dc:ac:11:dd:43:
|
||||
12:f8:ef:f2:f1:af:84:fd:83:fe:a8:d3:46:7d:77:
|
||||
e6:ae:95:61:a6:c9:99:6b:40:61:8d:6e:7e:66:1e:
|
||||
97:77:b0:e8:b7:3d:3a:d5:d7:d3:ee:66:95:62:83:
|
||||
14:cc:5e:32:ff:9e:bd:f1:06:e6:8d:6a:7c:0a:27:
|
||||
22:19:b9:06:09:cf:ef:c7:dc:e8:8f:04:4b:83:0d:
|
||||
cc:8d:b1:c2:cf:ab:40:25:6e:f2:bf:b7:c6:1d:8f:
|
||||
d2:fc:3d:c8:a1:be:4a:09:b9:91:e3:76:4f:c7:9b:
|
||||
fc:2f:de:d9:bb:eb:df:d3:d8:8c:72:79:bd:bf:10:
|
||||
8b:01:e6:0f:7f:bb:f6:75:31:5a:40:ad:df:e1:07:
|
||||
e6:12:12:b2:d3:99:d0:bd:24:5a:9a:ce:62:4f:da:
|
||||
fe:0d:df:09:ae:da:04:83:54:e8:cb:68:c0:57:78:
|
||||
c2:f4:68:42:d7:f4:81:4a:a3:b4:4e:0b:49:95:26:
|
||||
1d:15
|
||||
Exponent: 65537 (0x10001)
|
||||
X509v3 extensions:
|
||||
X509v3 Basic Constraints:
|
||||
CA:FALSE
|
||||
X509v3 Subject Key Identifier:
|
||||
8E:99:9D:C0:70:98:57:16:08:8E:DF:6E:51:78:A6:86:18:FF:06:52
|
||||
X509v3 Authority Key Identifier:
|
||||
keyid:BA:CD:8E:03:77:4A:49:16:BB:D6:2E:6C:A3:9F:4B:C3:4C:5A:95:AB
|
||||
DirName:/CN=Easy-RSA CA
|
||||
serial:12:FD:5F:D3:28:D5:B6:4A:FE:44:D6:0B:22:D6:77:DD:E4:C9:C0:73
|
||||
X509v3 Extended Key Usage:
|
||||
TLS Web Server Authentication
|
||||
X509v3 Key Usage:
|
||||
Digital Signature, Key Encipherment
|
||||
X509v3 Subject Alternative Name:
|
||||
DNS:127.0.0.1, IP Address:127.0.0.1
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Signature Value:
|
||||
3e:56:20:f9:3b:fa:13:6e:7e:a9:80:a6:15:18:01:82:f1:b8:
|
||||
4d:1b:f1:ee:da:ed:50:f7:3b:13:01:a5:14:f9:4c:0e:34:57:
|
||||
dc:e6:d1:7e:02:30:af:3b:fd:c9:ae:18:16:c9:3b:0a:4e:20:
|
||||
da:cd:e8:cc:05:0c:b3:7d:6f:e5:15:ff:66:59:6b:fe:ff:1a:
|
||||
ef:ca:b5:3a:1a:ad:dd:f6:19:43:d9:2b:61:18:29:95:b4:0c:
|
||||
1e:b2:4a:ce:80:d3:1b:59:dc:62:ec:50:21:37:9c:2f:7a:4d:
|
||||
c2:ac:de:1b:1d:a3:25:e0:e8:33:42:cf:77:31:2a:f2:44:36:
|
||||
ef:59:89:da:6c:3e:9a:e8:d7:06:39:17:d5:78:82:6d:b6:63:
|
||||
3f:9a:40:3b:e6:12:58:52:3d:63:4e:85:0b:02:cb:40:d2:8a:
|
||||
59:8d:8f:ee:4a:c8:97:91:51:a9:2f:1b:15:81:9c:20:dd:94:
|
||||
08:6f:ac:fa:c6:28:90:6c:17:5a:23:87:9a:5b:e5:c6:2e:f3:
|
||||
09:66:de:76:1b:60:42:c1:5c:71:88:87:f6:7b:cb:e3:7e:14:
|
||||
67:c9:a0:15:98:b6:7b:75:40:9a:08:fc:77:39:3a:23:cb:e3:
|
||||
78:7d:57:f9:a7:66:36:b4:b5:07:de:61:3a:dd:07:58:b3:4f:
|
||||
41:f6:f4:d9
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDhDCCAmygAwIBAgIQQjilb3BTQOSkGiwP/IETQjANBgkqhkiG9w0BAQsFADAW
|
||||
MRQwEgYDVQQDDAtFYXN5LVJTQSBDQTAeFw0yNjA0MjkxMjA5MzVaFw0yODA4MDEx
|
||||
MjA5MzVaMCUxIzAhBgNVBAMMGmV4YW1wbGVfc3NsX3JjcGNfcmNwc19jZXJ0MIIB
|
||||
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArg/h7vz223VFwPRJckY949sM
|
||||
xDTSnknUhk8ZDVVwUIHk5mRWqFjo5lQKFrz0S4TNHbku7WK2zWI1i4EYq/9j9cHc
|
||||
Fj6o3KwR3UMS+O/y8a+E/YP+qNNGfXfmrpVhpsmZa0BhjW5+Zh6Xd7Dotz061dfT
|
||||
7maVYoMUzF4y/5698QbmjWp8CiciGbkGCc/vx9zojwRLgw3MjbHCz6tAJW7yv7fG
|
||||
HY/S/D3Iob5KCbmR43ZPx5v8L97Zu+vf09iMcnm9vxCLAeYPf7v2dTFaQK3f4Qfm
|
||||
EhKy05nQvSRams5iT9r+Dd8JrtoEg1Toy2jAV3jC9GhC1/SBSqO0TgtJlSYdFQID
|
||||
AQABo4G+MIG7MAkGA1UdEwQCMAAwHQYDVR0OBBYEFI6ZncBwmFcWCI7fblF4poYY
|
||||
/wZSMFEGA1UdIwRKMEiAFLrNjgN3SkkWu9YubKOfS8NMWpWroRqkGDAWMRQwEgYD
|
||||
VQQDDAtFYXN5LVJTQSBDQYIUEv1f0yjVtkr+RNYLItZ33eTJwHMwEwYDVR0lBAww
|
||||
CgYIKwYBBQUHAwEwCwYDVR0PBAQDAgWgMBoGA1UdEQQTMBGCCTEyNy4wLjAuMYcE
|
||||
fwAAATANBgkqhkiG9w0BAQsFAAOCAQEAPlYg+Tv6E25+qYCmFRgBgvG4TRvx7trt
|
||||
UPc7EwGlFPlMDjRX3ObRfgIwrzv9ya4YFsk7Ck4g2s3ozAUMs31v5RX/Zllr/v8a
|
||||
78q1Ohqt3fYZQ9krYRgplbQMHrJKzoDTG1ncYuxQITecL3pNwqzeGx2jJeDoM0LP
|
||||
dzEq8kQ271mJ2mw+mujXBjkX1XiCbbZjP5pAO+YSWFI9Y06FCwLLQNKKWY2P7krI
|
||||
l5FRqS8bFYGcIN2UCG+s+sYokGwXWiOHmlvlxi7zCWbedhtgQsFccYiH9nvL434U
|
||||
Z8mgFZi2e3VAmgj8dzk6I8vjeH1X+admNrS1B95hOt0HWLNPQfb02Q==
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCuD+Hu/PbbdUXA
|
||||
9ElyRj3j2wzENNKeSdSGTxkNVXBQgeTmZFaoWOjmVAoWvPRLhM0duS7tYrbNYjWL
|
||||
gRir/2P1wdwWPqjcrBHdQxL47/Lxr4T9g/6o00Z9d+aulWGmyZlrQGGNbn5mHpd3
|
||||
sOi3PTrV19PuZpVigxTMXjL/nr3xBuaNanwKJyIZuQYJz+/H3OiPBEuDDcyNscLP
|
||||
q0AlbvK/t8Ydj9L8PcihvkoJuZHjdk/Hm/wv3tm769/T2Ixyeb2/EIsB5g9/u/Z1
|
||||
MVpArd/hB+YSErLTmdC9JFqazmJP2v4N3wmu2gSDVOjLaMBXeML0aELX9IFKo7RO
|
||||
C0mVJh0VAgMBAAECggEAGBhmQhdeE+Cu1Ihsn2dWW3PAF2wpiNR3GVWbRfOBHf/x
|
||||
QCx9K4ZNTU8ua1niZo7edyIiuyVaYWGaQHLRR8QNoiBhN2oapZujSHInzvKmeqr9
|
||||
ubt7NgMzQ5ykwB+5OiW3uXda2cGFOV08QgspF+6ftakQMzUbslyrdSQIIscmi5Ya
|
||||
uTDvE6+lBFinxy3RHFKVCZ3UrsDwfHR4eTUmgCHRB27joB7DFXL32amv0M8HjoGz
|
||||
EZKGJgTwmRf9U4z4D4wCnOfVAPlsuthKUqMuTlBg0ZEstMZrzlP4suT2ieku0Usv
|
||||
0XbJ38VozPYYFdR7nApVVvrJgHzI9cpoUbGto4BLOQKBgQDbXjFVLffOec8hv9dN
|
||||
2VGZQmK61S9OrbvTnEOlxJd+kRid71X1pV5TuPKJJQtUJXf429bQOs40YbLeOmJt
|
||||
BiRSR5yIBH7hDDC/c0ynqunstwDlgz+QX2Oh2B4alvVaWy0rZYF6NpBiI0+R5r5V
|
||||
C2fHRS4LLPoflg83+CMubyLS6QKBgQDLIOXxlp1JQTzXhJkrkytLkafmEHAafovt
|
||||
wbRD50/s+dl16BRX12sK0gXj2vwu0FleUD6Z7afDfspmvQdg3fyDxYw9Q+vw5LYQ
|
||||
7BvoVU99o1m468yXwX/v36peCt4nOpwkJZKJfjgxjnMJByyeSUgL9uW4K+0D0LBV
|
||||
a5Iv7QklTQKBgH30BkVPIHKIE/rfyIJlXemuaTu2/fOh4y9sEJdUWluMeeLssaFa
|
||||
ct+FWJSQFYIaBVl4+E0VBqKi2e2o/ix1E1O+1ExwsF0M/8xdKk024BtPNA+TnWKK
|
||||
so0Rpq9Dr9pScYvyOzZtr9b5SU2PfAcehlavDPHTwEV0hoZvTdvyab9JAoGADMBJ
|
||||
7vp3cSvJN/Y470VTyHCiS4zonKEpA4nPWRviJowgnIgvDryVGZ7Jg94xSncFxSfg
|
||||
ZiVHDLye1Ag1uFz3BwaVoRrsarjQvQs1TUZdsRNaBIO42iXpdBNkTHb+LxQ8zQAW
|
||||
zM7BlErO6dgrctxCy416Ki+Ht1+YUiRojt2gX1kCgYBqytUy+XkPi5j3Ga29xcvP
|
||||
WI3Uc8RI2GmoAmrw5QFiSG6lNXAzfo2ZNQbFnxgxeMOG9fV9yzBdIjXWNWr0E/KH
|
||||
Fsb65R8iIrXQB9BZjuQqjz9nDm7eZZUBNGGbQ4DgSepnp194gXC5DoAElzuwOXbE
|
||||
pY/kM1KwlpUR3J3LeF3i+Q==
|
||||
-----END PRIVATE KEY-----
|
||||
115
contrib/rcp/usage_example/certs/example_ssl_rcpm_rcps_cert.pem
Normal file
115
contrib/rcp/usage_example/certs/example_ssl_rcpm_rcps_cert.pem
Normal file
@@ -0,0 +1,115 @@
|
||||
Certificate:
|
||||
Data:
|
||||
Version: 3 (0x2)
|
||||
Serial Number:
|
||||
e7:09:ab:70:b5:dc:1f:11:d9:2a:23:04:39:87:34:f3
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Issuer: CN=Easy-RSA CA
|
||||
Validity
|
||||
Not Before: Apr 29 12:09:35 2026 GMT
|
||||
Not After : Aug 1 12:09:35 2028 GMT
|
||||
Subject: CN=example_ssl_rcpm_rcps_cert
|
||||
Subject Public Key Info:
|
||||
Public Key Algorithm: rsaEncryption
|
||||
Public-Key: (2048 bit)
|
||||
Modulus:
|
||||
00:cc:79:9b:d3:f3:1f:41:9f:00:48:cd:47:0b:ae:
|
||||
b9:1c:4e:3e:55:e2:4e:5f:a8:cc:13:d5:dd:bd:f0:
|
||||
01:4c:19:ae:e3:a9:09:06:89:92:49:f7:bb:90:28:
|
||||
fb:8c:22:69:b5:f5:a0:50:3d:97:0f:1e:1d:b1:a8:
|
||||
57:9b:d7:e2:0d:99:67:7f:02:82:0c:9c:8e:dd:13:
|
||||
03:28:93:b5:cb:7e:b5:78:06:10:bf:7b:55:c3:f7:
|
||||
10:8b:20:4a:1c:f9:f1:b2:fa:f1:c7:44:9d:0a:ce:
|
||||
ef:8d:f9:e8:ff:d1:c1:69:ec:8e:5f:11:cc:c9:98:
|
||||
d5:1c:33:e2:5b:7a:4d:34:dc:76:c3:cd:db:4c:93:
|
||||
d1:08:78:6f:3c:9a:ee:74:39:1e:cd:65:1e:c9:35:
|
||||
cc:3b:2b:9e:d7:49:10:8e:58:85:b0:10:5b:90:1e:
|
||||
f1:5e:d5:92:04:93:f9:33:c6:9d:77:63:d1:33:46:
|
||||
5b:98:ff:9a:a8:f5:df:f7:84:21:e2:88:28:7a:a4:
|
||||
c6:0d:9f:25:7e:0d:73:5b:d5:53:4a:90:79:94:37:
|
||||
14:f3:c8:75:76:d4:1c:32:51:bf:58:16:74:d5:8d:
|
||||
18:b6:53:f4:ab:cb:91:a8:8c:a3:ca:3c:5c:35:b6:
|
||||
5f:62:57:37:5a:75:28:b7:4d:26:aa:ea:50:da:a4:
|
||||
1c:55
|
||||
Exponent: 65537 (0x10001)
|
||||
X509v3 extensions:
|
||||
X509v3 Basic Constraints:
|
||||
CA:FALSE
|
||||
X509v3 Subject Key Identifier:
|
||||
47:92:B5:81:8B:5C:14:98:B3:83:B6:EB:06:9F:43:F3:3A:7E:ED:24
|
||||
X509v3 Authority Key Identifier:
|
||||
keyid:BA:CD:8E:03:77:4A:49:16:BB:D6:2E:6C:A3:9F:4B:C3:4C:5A:95:AB
|
||||
DirName:/CN=Easy-RSA CA
|
||||
serial:12:FD:5F:D3:28:D5:B6:4A:FE:44:D6:0B:22:D6:77:DD:E4:C9:C0:73
|
||||
X509v3 Extended Key Usage:
|
||||
TLS Web Server Authentication
|
||||
X509v3 Key Usage:
|
||||
Digital Signature, Key Encipherment
|
||||
X509v3 Subject Alternative Name:
|
||||
DNS:127.0.0.1, IP Address:127.0.0.1
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Signature Value:
|
||||
6d:31:e6:29:d2:3b:a8:90:5c:4b:ac:61:15:95:5d:70:66:a5:
|
||||
77:9d:88:47:49:73:75:be:70:69:d8:2f:62:82:5e:83:86:3b:
|
||||
a8:48:3f:f1:5f:22:ae:81:23:64:c4:f2:2b:dd:4d:be:e5:6a:
|
||||
26:a5:ea:c7:ba:1b:3e:6a:34:03:5a:f1:49:28:5f:56:4a:a6:
|
||||
0e:1b:7a:07:48:76:95:b6:4b:f5:3f:b9:67:2e:e0:33:06:80:
|
||||
d4:d6:01:a5:76:01:c0:a5:18:e5:38:8b:52:73:6e:6d:45:50:
|
||||
b7:9a:ab:86:5d:e3:65:b4:b8:c7:ee:b2:dc:bf:e3:d5:bb:e4:
|
||||
91:eb:f5:0c:38:22:5e:37:54:9e:ba:96:25:10:04:18:23:f7:
|
||||
ae:73:4d:d0:aa:03:81:b4:89:36:97:15:da:1a:60:a0:98:5f:
|
||||
03:f8:1b:22:83:57:41:4b:12:28:7d:8d:ea:88:74:24:28:5c:
|
||||
53:41:89:5e:9a:da:fd:7b:bf:60:dc:de:9b:49:ce:5c:a3:b2:
|
||||
01:7d:1d:cb:28:8c:ba:f4:7b:5d:2b:cb:15:5b:2a:97:1a:d1:
|
||||
f9:e7:12:e3:43:b9:f4:2a:88:dd:6d:b6:a0:72:d3:bd:63:23:
|
||||
e9:d7:f0:ac:b5:6d:0d:f2:d9:8b:2c:c4:35:5b:4d:83:dc:e8:
|
||||
7d:0b:3d:a3
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDhTCCAm2gAwIBAgIRAOcJq3C13B8R2SojBDmHNPMwDQYJKoZIhvcNAQELBQAw
|
||||
FjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMjYwNDI5MTIwOTM1WhcNMjgwODAx
|
||||
MTIwOTM1WjAlMSMwIQYDVQQDDBpleGFtcGxlX3NzbF9yY3BtX3JjcHNfY2VydDCC
|
||||
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMx5m9PzH0GfAEjNRwuuuRxO
|
||||
PlXiTl+ozBPV3b3wAUwZruOpCQaJkkn3u5Ao+4wiabX1oFA9lw8eHbGoV5vX4g2Z
|
||||
Z38Cggycjt0TAyiTtct+tXgGEL97VcP3EIsgShz58bL68cdEnQrO74356P/RwWns
|
||||
jl8RzMmY1Rwz4lt6TTTcdsPN20yT0Qh4bzya7nQ5Hs1lHsk1zDsrntdJEI5YhbAQ
|
||||
W5Ae8V7VkgST+TPGnXdj0TNGW5j/mqj13/eEIeKIKHqkxg2fJX4Nc1vVU0qQeZQ3
|
||||
FPPIdXbUHDJRv1gWdNWNGLZT9KvLkaiMo8o8XDW2X2JXN1p1KLdNJqrqUNqkHFUC
|
||||
AwEAAaOBvjCBuzAJBgNVHRMEAjAAMB0GA1UdDgQWBBRHkrWBi1wUmLODtusGn0Pz
|
||||
On7tJDBRBgNVHSMESjBIgBS6zY4Dd0pJFrvWLmyjn0vDTFqVq6EapBgwFjEUMBIG
|
||||
A1UEAwwLRWFzeS1SU0EgQ0GCFBL9X9Mo1bZK/kTWCyLWd93kycBzMBMGA1UdJQQM
|
||||
MAoGCCsGAQUFBwMBMAsGA1UdDwQEAwIFoDAaBgNVHREEEzARggkxMjcuMC4wLjGH
|
||||
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAG0x5inSO6iQXEusYRWVXXBmpXediEdJ
|
||||
c3W+cGnYL2KCXoOGO6hIP/FfIq6BI2TE8ivdTb7laial6se6Gz5qNANa8UkoX1ZK
|
||||
pg4begdIdpW2S/U/uWcu4DMGgNTWAaV2AcClGOU4i1Jzbm1FULeaq4Zd42W0uMfu
|
||||
sty/49W75JHr9Qw4Il43VJ66liUQBBgj965zTdCqA4G0iTaXFdoaYKCYXwP4GyKD
|
||||
V0FLEih9jeqIdCQoXFNBiV6a2v17v2Dc3ptJzlyjsgF9HcsojLr0e10ryxVbKpca
|
||||
0fnnEuNDufQqiN1ttqBy071jI+nX8Ky1bQ3y2YssxDVbTYPc6H0LPaM=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMeZvT8x9BnwBI
|
||||
zUcLrrkcTj5V4k5fqMwT1d298AFMGa7jqQkGiZJJ97uQKPuMImm19aBQPZcPHh2x
|
||||
qFeb1+INmWd/AoIMnI7dEwMok7XLfrV4BhC/e1XD9xCLIEoc+fGy+vHHRJ0Kzu+N
|
||||
+ej/0cFp7I5fEczJmNUcM+Jbek003HbDzdtMk9EIeG88mu50OR7NZR7JNcw7K57X
|
||||
SRCOWIWwEFuQHvFe1ZIEk/kzxp13Y9EzRluY/5qo9d/3hCHiiCh6pMYNnyV+DXNb
|
||||
1VNKkHmUNxTzyHV21BwyUb9YFnTVjRi2U/Sry5GojKPKPFw1tl9iVzdadSi3TSaq
|
||||
6lDapBxVAgMBAAECggEADJFN6K9OWhYX1PcEWUgOLxqdCLd95Iccsfxot7ekcMUP
|
||||
A4WnHRyACLqor9c2V3o2//IpU2fnB2IXu6ISmRd3WKl3hm4vnZmoIJeTpQm9Iv/g
|
||||
+fqkyrbIgktcHDJUySal+n+jiYFNW2B1h1xXUT/scMz+FthNJg1Azfi0vorMFjCk
|
||||
SBOSo7BQ2hiQ83FneVJU1TsxD4S4IBLx9fF6AW05norRmvm17Ip2pKk4QYzKiOI3
|
||||
NIoSwbOgU0Vp1X3MMlilM5ZZdN3a9lI6lfBZE682WOxBmH67mKMQnR8aC+nwynQ+
|
||||
45pUQIn8Fjx2hQHehbD7ZNw9Nob9AyGWqWGKFV74IQKBgQDox/j7dCHNm1mz1QRm
|
||||
Q2YyN4OGXJI97t9Gd8UdNCv5bAkdx2cR2lmP3tRc6iAzrfNIpPCGaXo1vwJx477X
|
||||
wO95W2b4hfm3j6v0cqRbsFzzVIHZUB77pXfAJfZeICpoqu0vxn5nb+yPgzgmToLX
|
||||
pbIDdqWafzzrDLTmLCfwfKDI9QKBgQDg3tgpAXo8WCL4phNuW44XQ172lPTijpn+
|
||||
wj1Z0rBrS806gL/+QvZZLS1WCym/QBV7TgGlxIJbmAfghcGyin3NliskSHAiccxG
|
||||
/9eCSQes8czfsVj6qmBMwyff5r+wmk662qV0u07UHmuykYk3Dgs/zYdwq2SsTlL4
|
||||
Y9eRjutp4QKBgQCONg0wYcR8/hmROeRULXzz1OJvZYKaf6K8RFOSAduTp6LyJG4d
|
||||
hA4PTQzkLsy5hd4JVWr0UuAskaMGvSJMYTxsIaEI16C1ufpNfvRWZ6qBpfEmOEKV
|
||||
boN4Sjj3TCNcioAZHeT/gGs/SeU10eUxpbLZVtTZTD6FQuAJdpR34UvBOQKBgFNM
|
||||
mXxPLM2vxHyhYK9PwQoDDel/8lr+gjMqFvnwHyQP911FllmEyqbsIlAuYG+VOJ/t
|
||||
nJSgf72YSsq0IbWWsdV3XFHbd5Z62zYtzdJYZTx+cesnUhPBC11EKcA6RSYRczqq
|
||||
hgIA5MmU30ZNvSukyyv+Yb6t7uQZO4kByzgDXldhAoGBALgRkAHxgbKUXp5XyCDJ
|
||||
e8dwVx0g9tfDM/DEZtU/Si5oUaunBaPV/Byov7OXOT02V8JLnA5ChUYgwUFI030x
|
||||
QL/3eK12Qh5Gb9VabvYCicDRk4GzmqZU9Wcvm1zgbUr5jY8Lou44nFjol/Y1m70n
|
||||
51WZbVkkWmBZO5m3NqN66SkJ
|
||||
-----END PRIVATE KEY-----
|
||||
115
contrib/rcp/usage_example/certs/example_ssl_rcps_rcpm_cert.pem
Normal file
115
contrib/rcp/usage_example/certs/example_ssl_rcps_rcpm_cert.pem
Normal file
@@ -0,0 +1,115 @@
|
||||
Certificate:
|
||||
Data:
|
||||
Version: 3 (0x2)
|
||||
Serial Number:
|
||||
28:96:2e:a1:40:e0:7e:f1:fb:63:1a:f4:53:6f:ce:fb
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Issuer: CN=Easy-RSA CA
|
||||
Validity
|
||||
Not Before: Apr 29 12:09:35 2026 GMT
|
||||
Not After : Aug 1 12:09:35 2028 GMT
|
||||
Subject: CN=example_ssl_rcps_rcpm_cert
|
||||
Subject Public Key Info:
|
||||
Public Key Algorithm: rsaEncryption
|
||||
Public-Key: (2048 bit)
|
||||
Modulus:
|
||||
00:ae:11:46:ef:d1:81:34:dd:23:5d:54:40:f3:9c:
|
||||
85:35:95:a6:91:57:92:5c:bf:eb:40:34:69:eb:db:
|
||||
c0:86:3c:7b:ff:9c:d7:ba:0e:41:57:84:15:cd:94:
|
||||
f1:48:63:50:9c:34:97:ee:be:be:b0:27:d8:fd:cd:
|
||||
8a:cf:85:ff:08:1f:07:d8:28:96:0e:e4:2d:d0:8b:
|
||||
df:a8:fa:41:47:a0:a2:80:2e:2e:58:01:cc:6f:43:
|
||||
5c:c2:fb:84:a7:ff:9e:97:bb:b3:a3:1f:63:64:73:
|
||||
8d:73:dd:f4:7e:96:d7:6b:b3:cb:e2:35:59:55:e0:
|
||||
e7:e3:c0:41:f8:b6:0f:c5:46:4c:cd:0e:91:80:ef:
|
||||
e3:43:f0:72:26:12:10:be:83:a2:db:23:2d:b4:b1:
|
||||
07:5a:b1:b3:10:9c:09:69:98:42:79:81:77:5e:22:
|
||||
e4:71:47:70:27:15:2c:a7:13:c2:6d:44:59:b4:73:
|
||||
c9:bb:27:7f:d6:e8:3d:85:bb:36:f6:cb:71:36:11:
|
||||
b1:99:1a:1d:1a:15:dd:cd:65:7f:cd:cc:10:00:49:
|
||||
ed:07:2d:7b:15:88:be:73:ba:1d:15:69:bc:d3:02:
|
||||
55:ea:dc:2c:3f:0b:cd:18:57:59:7a:e3:09:b2:89:
|
||||
cd:d6:e7:f6:95:c4:2e:8a:53:2b:a8:96:82:94:53:
|
||||
00:77
|
||||
Exponent: 65537 (0x10001)
|
||||
X509v3 extensions:
|
||||
X509v3 Basic Constraints:
|
||||
CA:FALSE
|
||||
X509v3 Subject Key Identifier:
|
||||
60:BD:48:06:68:15:D4:DC:ED:EE:E4:C7:B1:9F:C4:93:6D:50:3A:77
|
||||
X509v3 Authority Key Identifier:
|
||||
keyid:BA:CD:8E:03:77:4A:49:16:BB:D6:2E:6C:A3:9F:4B:C3:4C:5A:95:AB
|
||||
DirName:/CN=Easy-RSA CA
|
||||
serial:12:FD:5F:D3:28:D5:B6:4A:FE:44:D6:0B:22:D6:77:DD:E4:C9:C0:73
|
||||
X509v3 Extended Key Usage:
|
||||
TLS Web Server Authentication
|
||||
X509v3 Key Usage:
|
||||
Digital Signature, Key Encipherment
|
||||
X509v3 Subject Alternative Name:
|
||||
DNS:127.0.0.1, IP Address:127.0.0.1
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Signature Value:
|
||||
c5:35:61:58:23:e2:69:da:6c:d5:41:ab:a8:70:f4:dd:cc:a0:
|
||||
a3:3d:84:89:93:b6:7f:69:7d:10:35:9d:c5:d1:0d:db:d2:d7:
|
||||
36:af:d4:54:30:14:a7:5d:31:ca:5c:13:92:d5:60:50:f8:56:
|
||||
4a:cb:16:b1:b3:b1:03:bf:96:53:77:1f:4a:0f:9c:29:2b:bf:
|
||||
a4:e0:da:6f:ad:13:c7:2d:8e:18:c4:72:50:17:ed:1f:36:51:
|
||||
7a:12:9f:fc:a6:d6:c8:55:e0:db:ea:16:d6:22:0d:a2:cb:eb:
|
||||
b2:ba:07:92:2f:db:33:d6:a2:0c:ec:89:29:f1:96:40:e5:0b:
|
||||
e6:1f:08:50:d6:29:87:a8:20:b2:e2:17:50:25:ff:53:36:ee:
|
||||
7f:ce:e6:1d:ed:b3:16:61:18:42:a9:17:9e:a6:86:0d:a5:fc:
|
||||
f9:42:c8:50:48:74:72:35:eb:8c:ff:4d:e8:98:88:a0:b4:b3:
|
||||
d0:82:b3:2f:ea:19:d7:d5:ac:47:35:96:24:37:34:0c:7a:a2:
|
||||
e0:4d:99:a7:55:61:85:1e:7e:6a:23:77:f5:07:13:e6:50:5c:
|
||||
65:00:13:f6:b5:4b:5b:8c:11:c3:5d:af:ba:41:e9:84:1d:f1:
|
||||
a4:70:16:28:c2:be:6e:d8:67:38:c5:a0:ba:8a:64:6f:27:ce:
|
||||
63:a0:92:9b
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDhDCCAmygAwIBAgIQKJYuoUDgfvH7Yxr0U2/O+zANBgkqhkiG9w0BAQsFADAW
|
||||
MRQwEgYDVQQDDAtFYXN5LVJTQSBDQTAeFw0yNjA0MjkxMjA5MzVaFw0yODA4MDEx
|
||||
MjA5MzVaMCUxIzAhBgNVBAMMGmV4YW1wbGVfc3NsX3JjcHNfcmNwbV9jZXJ0MIIB
|
||||
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArhFG79GBNN0jXVRA85yFNZWm
|
||||
kVeSXL/rQDRp69vAhjx7/5zXug5BV4QVzZTxSGNQnDSX7r6+sCfY/c2Kz4X/CB8H
|
||||
2CiWDuQt0IvfqPpBR6CigC4uWAHMb0NcwvuEp/+el7uzox9jZHONc930fpbXa7PL
|
||||
4jVZVeDn48BB+LYPxUZMzQ6RgO/jQ/ByJhIQvoOi2yMttLEHWrGzEJwJaZhCeYF3
|
||||
XiLkcUdwJxUspxPCbURZtHPJuyd/1ug9hbs29stxNhGxmRodGhXdzWV/zcwQAEnt
|
||||
By17FYi+c7odFWm80wJV6twsPwvNGFdZeuMJsonN1uf2lcQuilMrqJaClFMAdwID
|
||||
AQABo4G+MIG7MAkGA1UdEwQCMAAwHQYDVR0OBBYEFGC9SAZoFdTc7e7kx7GfxJNt
|
||||
UDp3MFEGA1UdIwRKMEiAFLrNjgN3SkkWu9YubKOfS8NMWpWroRqkGDAWMRQwEgYD
|
||||
VQQDDAtFYXN5LVJTQSBDQYIUEv1f0yjVtkr+RNYLItZ33eTJwHMwEwYDVR0lBAww
|
||||
CgYIKwYBBQUHAwEwCwYDVR0PBAQDAgWgMBoGA1UdEQQTMBGCCTEyNy4wLjAuMYcE
|
||||
fwAAATANBgkqhkiG9w0BAQsFAAOCAQEAxTVhWCPiadps1UGrqHD03cygoz2EiZO2
|
||||
f2l9EDWdxdEN29LXNq/UVDAUp10xylwTktVgUPhWSssWsbOxA7+WU3cfSg+cKSu/
|
||||
pODab60Txy2OGMRyUBftHzZRehKf/KbWyFXg2+oW1iINosvrsroHki/bM9aiDOyJ
|
||||
KfGWQOUL5h8IUNYph6ggsuIXUCX/Uzbuf87mHe2zFmEYQqkXnqaGDaX8+ULIUEh0
|
||||
cjXrjP9N6JiIoLSz0IKzL+oZ19WsRzWWJDc0DHqi4E2Zp1VhhR5+aiN39QcT5lBc
|
||||
ZQAT9rVLW4wRw12vukHphB3xpHAWKMK+bthnOMWguopkbyfOY6CSmw==
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCuEUbv0YE03SNd
|
||||
VEDznIU1laaRV5Jcv+tANGnr28CGPHv/nNe6DkFXhBXNlPFIY1CcNJfuvr6wJ9j9
|
||||
zYrPhf8IHwfYKJYO5C3Qi9+o+kFHoKKALi5YAcxvQ1zC+4Sn/56Xu7OjH2Nkc41z
|
||||
3fR+ltdrs8viNVlV4OfjwEH4tg/FRkzNDpGA7+ND8HImEhC+g6LbIy20sQdasbMQ
|
||||
nAlpmEJ5gXdeIuRxR3AnFSynE8JtRFm0c8m7J3/W6D2Fuzb2y3E2EbGZGh0aFd3N
|
||||
ZX/NzBAASe0HLXsViL5zuh0VabzTAlXq3Cw/C80YV1l64wmyic3W5/aVxC6KUyuo
|
||||
loKUUwB3AgMBAAECggEABGfLiSZJmYeUmDgZrLtkDlx16sJx9zGkR+u2V+cn6D3U
|
||||
+uiCoo2EedfjSrYKT/AI35Xf19sGrc6ptJgPJfwY3aEWFxJv5HtB7ZVHWS98QiPT
|
||||
+QqHgb1fPzGlQgoQ7Bo8GVBW1joPz0Bdbsv0ntTn1CyowauNUe8Z71mzpxJ0iQ7u
|
||||
Z8LNoD7INEAZDBjHVov6pLGDHS9KFxGy20WG549mE37I4QxyxetJEgmuyhJkZOQ4
|
||||
noEWiCMQjGsSg4YuSc1GS1jAVf3p2g3/TiheD/31r1jleY/T5s2qYC6MJ4vY+7yA
|
||||
5sl7m8A47i0lHSKBdR3nWz+vXEsxB0nXK3Sulyt9AQKBgQDYRZ1nEe32CCZcWn/7
|
||||
nG+9e6XNOe9E25skuvY/uEpikt92kdnyZOgHFfwM9Nv+w2IWGLZ8MmDDJ6/4hGvj
|
||||
fJjcOUb3d/SJiivPvdRC9GYMrDUZe5AJ3p6fPqi4IeZOw7bMTcVB6aHAr9KoO//J
|
||||
2t0WNvwzkOyl6KlhaQEIDK8bOQKBgQDOCvWvUEg8nHaKcmN0uQaKuPvCorhnyqUT
|
||||
VFqLlSrYC79ffHCwp2y8nkFUnxpeHXENrtHtcrdnKBNkgGEn2l4xs63CjhKrBuIG
|
||||
bDjrtp3vKlHRhQjX6HkrEEuUk52wYzuX7CfU8nTZnrtV74MocOEewJVW+84Rtwiw
|
||||
vIUcgfgJLwKBgAbgj9TLOSntsGqXZiJ2Iwd/exI/mWAzK4fLejEkhxkDWp/Gm4ud
|
||||
sdMn28/9qVE8nU3ek073uyP5ixr3+wZM2/+EwsDzy47kGeiNPMa0Rtp4T2f0CeyG
|
||||
a7zcnTjduxkeGB3/CxrBdydNcAFxhvzAPO+L6BErtpq//0Ldt+6tmJPhAoGAFxEh
|
||||
Cjx5qdd2ae9+dO3V7qfg/5xJ+sy0CGL0NBZCEqfWB/GdiBlmUgOBmuCpCgpPwtFk
|
||||
jSm/oJva9/BrcBPBYd0Uweg37M+7dC6ffLwYGFNrj4JOSCWtkwWjAII6MCob3NlC
|
||||
aFOwg0CDBo7m5xskCNZUocVU/6S3I1onqNZgF18CgYAczBf1NS4VPZvebrutTBEH
|
||||
zyUX3XU9mR+dy0ncCGNVS8zYMtz7cweZInzNB2cTOfisIHzzdOnazm7D9uzPsREi
|
||||
pKgaL+ErWYDlGiDTxMtGSRPTWGocYBYdU6y/0bobhZb0qyvyRhpGvPK0ReMUuvqu
|
||||
FkNgoQ1lo0n6vawvxWW8Mw==
|
||||
-----END PRIVATE KEY-----
|
||||
49
contrib/rcp/usage_example/certs/make_certs.sh
Executable file
49
contrib/rcp/usage_example/certs/make_certs.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
EASYRSA=/usr/share/easy-rsa/easyrsa
|
||||
CA_NAME="example_ssl_rcp_ca_cert"
|
||||
|
||||
export EASYRSA_PASSIN=pass:test
|
||||
export EASYRSA_PASSOUT=pass:test
|
||||
|
||||
echo "Cleaning up..."
|
||||
rm -rf ./ca
|
||||
rm -rf ./*.pem
|
||||
rm -rf ./*.key
|
||||
rm -rf ./*.crt
|
||||
|
||||
echo "Creating CA cert..."
|
||||
mkdir -p ./ca
|
||||
cd ./ca
|
||||
$EASYRSA init-pki
|
||||
cp ../vars ./pki/
|
||||
$EASYRSA --batch build-ca
|
||||
cp ./pki/ca.crt ../$CA_NAME.crt
|
||||
|
||||
echo "Creating server certs..."
|
||||
# Secures connection between RCP-Client and RCP-Server:
|
||||
$EASYRSA --batch --subject-alt-name="DNS:127.0.0.1,IP:127.0.0.1" build-server-full example_ssl_rcpc_rcps_cert nopass
|
||||
|
||||
# Secures connection between RCP-Module and RCP-Server (module description):
|
||||
$EASYRSA --batch --subject-alt-name="DNS:127.0.0.1,IP:127.0.0.1" build-server-full example_ssl_rcpm_rcps_cert nopass
|
||||
|
||||
# Secures connection between RCP-Server and RCP-Module (command execution):
|
||||
$EASYRSA --batch --subject-alt-name="DNS:127.0.0.1,IP:127.0.0.1" build-server-full example_ssl_rcps_rcpm_cert nopass
|
||||
|
||||
echo "Collecting server certs..."
|
||||
cp ./pki/issued/* ../
|
||||
cp ./pki/private/* ../
|
||||
cd ..
|
||||
rm ./ca.key
|
||||
|
||||
echo "Merging server certs..."
|
||||
for CRT in ./*.crt; do
|
||||
CRT_NAME=`basename ${CRT%.*}`
|
||||
if [ -f $CRT_NAME.key ]; then
|
||||
cat $CRT_NAME.crt $CRT_NAME.key > $CRT_NAME.pem
|
||||
rm $CRT_NAME.key
|
||||
rm $CRT_NAME.crt
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Finalizing..."
|
||||
rm -rf ./ca
|
||||
36
contrib/rcp/usage_example/params.cfg
Normal file
36
contrib/rcp/usage_example/params.cfg
Normal file
@@ -0,0 +1,36 @@
|
||||
# PYSIM_DIR passed to all components
|
||||
PYSIM_DIR=../../../ # Points to the psyim top directory
|
||||
|
||||
# Verbosity switch passed to all components (comment-out to disable verbode mode)
|
||||
#VERBOSE="--verbose"
|
||||
|
||||
# PCSC reader that the RCP Client shall use
|
||||
PCSC_READER=0
|
||||
|
||||
# Since RCP Modules are custom implementations, they will most likely reside
|
||||
# in a dedicated directory. This directory is passed together with PYSIM_DIR
|
||||
# via PYTHONPATH to the module.
|
||||
RCP_DIR=../
|
||||
|
||||
# CA of the certificates used in this example
|
||||
CA_CERT="./certs/example_ssl_rcp_ca_cert.crt"
|
||||
|
||||
# Network interface where RCP Clients connect
|
||||
RCPC_SERVER_PORT=8000
|
||||
RCPC_SERVER_ADDR="127.0.0.1"
|
||||
RCPC_SERVER_CERT="./certs/example_ssl_rcpc_rcps_cert.pem"
|
||||
RCPC_SERVER_URI="wss://$RCPC_SERVER_ADDR:$RCPC_SERVER_PORT"
|
||||
|
||||
# Network interface where RCP Modules connect
|
||||
RCPM_SERVER_PORT=8010
|
||||
RCPM_SERVER_ADDR="127.0.0.1"
|
||||
RCPM_SERVER_CERT="./certs/example_ssl_rcpm_rcps_cert.pem"
|
||||
RCPM_SERVER_URI="wss://$RCPM_SERVER_ADDR:$RCPM_SERVER_PORT"
|
||||
|
||||
# Network interface where the (example) RCP Module binds its Command Server to.
|
||||
# The command server is used by the RCP Server to run the command requested
|
||||
# by the user. Each module needs a dedicated port. The address and port is
|
||||
# automatically forwarded to the RCP Server.
|
||||
RCPM_CMD_SERVER_PORT=8020
|
||||
RCPM_CMD_SERVER_ADDR="127.0.0.1"
|
||||
RCPM_CMD_SERVER_CERT="./certs/example_ssl_rcps_rcpm_cert.pem"
|
||||
86
contrib/rcp/usage_example/rcp_module.py
Executable file
86
contrib/rcp/usage_example/rcp_module.py
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/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 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 logging
|
||||
from pathlib import Path
|
||||
from pySim.log import PySimLogger
|
||||
from argparse import Namespace
|
||||
from rcp_module_utils import rcpm_setup_argparse, rcpm_run_module, RcpModule, RcpmCmdSrvConnHdlr
|
||||
|
||||
log = PySimLogger.get(Path(__file__).stem)
|
||||
|
||||
class ExmpleModule(RcpModule):
|
||||
|
||||
name = Path(__file__).stem
|
||||
cmd_descr = [{"name" : "reset",
|
||||
"help": "reset the card",
|
||||
"args" : []},
|
||||
{"name" : "read_binary",
|
||||
"help": "read binary data from a transparent file.",
|
||||
"args" : [{ "name" : "--fid",
|
||||
"spec" : {"required" : True,
|
||||
"help" : "File identifier to of the file to read",
|
||||
"action" : "append",
|
||||
"pytype" : "str"},
|
||||
}
|
||||
]},
|
||||
{"name" : "read_record",
|
||||
"help": "read binary data from a transparent file.",
|
||||
"args" : [{ "name" : "--fid",
|
||||
"spec" : {"required" : True,
|
||||
"help" : "File identifier to of the file to read",
|
||||
"action" : "append",
|
||||
"pytype" : "str"},
|
||||
},
|
||||
{ "name" : "--record",
|
||||
"spec" : {"required" : True,
|
||||
"help" : "File record to read",
|
||||
"default" : 1,
|
||||
"pytype" : "int"},
|
||||
}
|
||||
]}
|
||||
]
|
||||
suitable_for = [{"atr" : "3b9f96803f87828031e073fe211f574543753130136502"}]
|
||||
|
||||
def cmd_reset(self, hdlr: RcpmCmdSrvConnHdlr) -> int:
|
||||
hdlr.print("resetting UICC/eUICC")
|
||||
hdlr.scc.reset_card()
|
||||
hdlr.print("ATR is: %s" % hdlr.scc.get_atr())
|
||||
return 0
|
||||
|
||||
def cmd_read_binary(self, hdlr: RcpmCmdSrvConnHdlr) -> int:
|
||||
fid = hdlr.cmd_args.fid
|
||||
hdlr.print("reading transparent file: %s" % fid)
|
||||
(res, _) = hdlr.scc.read_binary(fid)
|
||||
hdlr.print("file content is: %s" % res)
|
||||
return 0
|
||||
|
||||
def cmd_read_record(self, hdlr: RcpmCmdSrvConnHdlr) -> int:
|
||||
fid = hdlr.cmd_args.fid
|
||||
record = hdlr.cmd_args.record
|
||||
hdlr.print("reading linear-fixed file: %s" % fid)
|
||||
(res, _) = hdlr.scc.read_record(fid, record)
|
||||
hdlr.print("file content is: %s" % res)
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
option_parser = rcpm_setup_argparse("Example Module")
|
||||
opts = option_parser.parse_args()
|
||||
rcpm_run_module(opts, ExmpleModule)
|
||||
14
contrib/rcp/usage_example/readme.txt
Normal file
14
contrib/rcp/usage_example/readme.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
How to try:
|
||||
|
||||
Go to the directory that contains the usage example:
|
||||
cd pysim/contrib/rcp/usage_example
|
||||
|
||||
Start the RCP Server:
|
||||
./start_rcp_server.sh
|
||||
|
||||
Start the RCP Module:
|
||||
./start_rcp_module.sh
|
||||
|
||||
Run the exmple scripts:
|
||||
./run_rcp_client.sh
|
||||
(it is also possible to call the run_rcp_client_*.sh scripts individually)
|
||||
29
contrib/rcp/usage_example/run_rcp_client.sh
Executable file
29
contrib/rcp/usage_example/run_rcp_client.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "basic help"
|
||||
echo "===================================================================================="
|
||||
./run_rcp_client_help.sh
|
||||
echo "===================================================================================="
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
echo "help for which commands are available"
|
||||
echo "===================================================================================="
|
||||
./run_rcp_client_help_cmd.sh
|
||||
echo "===================================================================================="
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
echo "help for specific commands"
|
||||
echo "===================================================================================="
|
||||
./run_rcp_client_help_cmd_specific.sh
|
||||
echo "===================================================================================="
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
echo "run specific RCP commands"
|
||||
echo "===================================================================================="
|
||||
./run_rcp_client_cmd.sh
|
||||
echo "===================================================================================="
|
||||
echo ""
|
||||
echo ""
|
||||
22
contrib/rcp/usage_example/run_rcp_client_cmd.sh
Executable file
22
contrib/rcp/usage_example/run_rcp_client_cmd.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
. ./params.cfg
|
||||
|
||||
set -x
|
||||
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
--uri $RCPC_SERVER_URI\
|
||||
--ca-cert $CA_CERT \
|
||||
-p $PCSC_READER \
|
||||
rcp_module_reset
|
||||
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
--uri $RCPC_SERVER_URI \
|
||||
--ca-cert $CA_CERT \
|
||||
-p $PCSC_READER \
|
||||
rcp_module_read_binary --fid 3f00 --fid 2fe2
|
||||
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
--uri $RCPC_SERVER_URI \
|
||||
--ca-cert $CA_CERT \
|
||||
-p $PCSC_READER \
|
||||
rcp_module_read_record --fid 3f00 --fid 2f00 --record 1
|
||||
6
contrib/rcp/usage_example/run_rcp_client_help.sh
Executable file
6
contrib/rcp/usage_example/run_rcp_client_help.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
. ./params.cfg
|
||||
|
||||
set -x
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
-h
|
||||
9
contrib/rcp/usage_example/run_rcp_client_help_cmd.sh
Executable file
9
contrib/rcp/usage_example/run_rcp_client_help_cmd.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
. ./params.cfg
|
||||
|
||||
set -x
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
--uri $RCPC_SERVER_URI \
|
||||
--ca-cert $CA_CERT \
|
||||
-p $PCSC_READER \
|
||||
-h
|
||||
22
contrib/rcp/usage_example/run_rcp_client_help_cmd_specific.sh
Executable file
22
contrib/rcp/usage_example/run_rcp_client_help_cmd_specific.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
. ./params.cfg
|
||||
|
||||
set -x
|
||||
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
--uri $RCPC_SERVER_URI \
|
||||
--ca-cert $CA_CERT \
|
||||
-p $PCSC_READER \
|
||||
rcp_module_reset --help
|
||||
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
--uri $RCPC_SERVER_URI \
|
||||
--ca-cert $CA_CERT \
|
||||
-p $PCSC_READER \
|
||||
rcp_module_read_binary --help
|
||||
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_client.py $VERBOSE \
|
||||
--uri $RCPC_SERVER_URI \
|
||||
--ca-cert $CA_CERT \
|
||||
-p $PCSC_READER \
|
||||
rcp_module_read_record --help
|
||||
11
contrib/rcp/usage_example/start_rcp_module.sh
Executable file
11
contrib/rcp/usage_example/start_rcp_module.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
. ./params.cfg
|
||||
|
||||
set -x
|
||||
PYTHONPATH=$PYSIM_DIR:$RCP_DIR ./rcp_module.py $VERBOSE \
|
||||
--uri $RCPM_SERVER_URI \
|
||||
--rcps-ca-cert $CA_CERT \
|
||||
--rcpm-cmd-server-addr $RCPM_CMD_SERVER_ADDR \
|
||||
--rcpm-cmd-server-port $RCPM_CMD_SERVER_PORT \
|
||||
--rcpm-cmd-server-cert $RCPM_CMD_SERVER_CERT
|
||||
|
||||
13
contrib/rcp/usage_example/start_rcp_server.sh
Executable file
13
contrib/rcp/usage_example/start_rcp_server.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
. ./params.cfg
|
||||
|
||||
set -x
|
||||
PYTHONPATH=$PYSIM_DIR ../rcp_server.py $VERBOSE \
|
||||
--rcpc-server-addr $RCPC_SERVER_ADDR \
|
||||
--rcpc-server-port $RCPC_SERVER_PORT \
|
||||
--rcpc-server-cert $RCPC_SERVER_CERT \
|
||||
--rcpm-server-addr $RCPM_SERVER_ADDR \
|
||||
--rcpm-server-port $RCPM_SERVER_PORT \
|
||||
--rcpm-server-cert $RCPM_SERVER_CERT \
|
||||
--rcpm-module-ca-cert $CA_CERT
|
||||
|
||||
@@ -56,7 +56,7 @@ parser_rpe.add_argument('--output-file', required=True, help='Output file name')
|
||||
parser_rpe.add_argument('--identification', default=[], type=int, action='append', help='Remove PEs matching specified identification')
|
||||
parser_rpe.add_argument('--type', default=[], action='append', help='Remove PEs matching specified type')
|
||||
|
||||
parser_rn = subparsers.add_parser('remove-naa', help='Remove speciifed NAAs from PE-Sequence')
|
||||
parser_rn = subparsers.add_parser('remove-naa', help='Remove specified NAAs from PE-Sequence')
|
||||
parser_rn.add_argument('--output-file', required=True, help='Output file name')
|
||||
parser_rn.add_argument('--naa-type', required=True, choices=NAAs.keys(), help='Network Access Application type to remove')
|
||||
# TODO: add an --naa-index or the like, so only one given instance can be removed
|
||||
@@ -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('--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):
|
||||
"""write the PE sequence to a file"""
|
||||
@@ -329,7 +329,7 @@ def do_info(pes: ProfileElementSequence, opts):
|
||||
print("Security domain Instance AID: %s" % b2h(sd.decoded['instance']['instanceAID']))
|
||||
# FIXME: 'applicationSpecificParametersC9' parsing to figure out enabled SCP
|
||||
for key in sd.keys:
|
||||
print("\tKVN=0x%02x, KID=0x%02x, %s" % (key.key_version_number, key.key_identifier, key.key_components))
|
||||
print("\t%s" % repr(key))
|
||||
|
||||
# RFM
|
||||
print()
|
||||
|
||||
@@ -27,5 +27,5 @@ PYTHONPATH=$PYSIMPATH python3 $PYSIMPATH/contrib/saip-tool.py $OUTPATH add-app-i
|
||||
# Display the contents of the resulting application PE:
|
||||
PYTHONPATH=$PYSIMPATH python3 $PYSIMPATH/contrib/saip-tool.py $OUTPATH info --apps
|
||||
|
||||
# For an explaination of --uicc-toolkit-app-spec-pars, see:
|
||||
# For an explanation of --uicc-toolkit-app-spec-pars, see:
|
||||
# ETSI TS 102 226, section 8.2.1.3.2.2.1
|
||||
|
||||
240
contrib/smpp-ota-tool.py
Executable file
240
contrib/smpp-ota-tool.py
Executable file
@@ -0,0 +1,240 @@
|
||||
#!/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, CNTR_REQ, RC_CC_DS, POR_REQ
|
||||
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('-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):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
||||
def __del__(self):
|
||||
if self.client:
|
||||
self.client.unbind()
|
||||
self.client.disconnect()
|
||||
|
||||
def message_received_handler(self, pdu):
|
||||
if pdu.short_message:
|
||||
logger.info("SMS-TPDU received: %s", b2h(pdu.short_message))
|
||||
try:
|
||||
dec = self.ota_dialect.decode_resp(self.ota_keyset, self.spi, pdu.short_message)
|
||||
except ValueError:
|
||||
# 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)
|
||||
spi = self.spi.copy()
|
||||
spi['por_shall_be_ciphered'] = False
|
||||
spi['por_rc_cc_ds'] = 'no_rc_cc_ds'
|
||||
dec = self.ota_dialect.decode_resp(self.ota_keyset, spi, pdu.short_message)
|
||||
logger.info("SMS-TPDU decoded: %s", dec)
|
||||
self.response = dec
|
||||
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
|
||||
"""
|
||||
|
||||
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)
|
||||
# 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')
|
||||
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}
|
||||
apdu = h2b("".join(opts.apdu))
|
||||
|
||||
smpp_handler = SmppHandler(opts.host, opts.port, opts.system_id, opts.password, ota_keyset, spi, h2b(opts.tar))
|
||||
resp, sw = smpp_handler.transceive_apdu(apdu, opts.src_addr, opts.dest_addr, opts.timeout)
|
||||
print("%s %s" % (b2h(resp), b2h(sw)))
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# A more useful verion of the 'unber' tool provided with asn1c:
|
||||
# A more useful version of the 'unber' tool provided with asn1c:
|
||||
# Give a hierarchical decode of BER/DER-encoded ASN.1 TLVs
|
||||
|
||||
import sys
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
derive card-specific key material from a global master key.
|
||||
|
||||
The only actual CardKeyProvider implementation included in pySim is the
|
||||
`CardKeyProviderCsv` which retrieves the key material from a
|
||||
[potentially encrypted] CSV file.
|
||||
pySim already includes two CardKeyProvider implementations. One to retrieve
|
||||
key material from a CSV file (`CardKeyProviderCsv`) and a second one that allows
|
||||
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
|
||||
@@ -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
|
||||
`~/.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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
---------------------------
|
||||
|
||||
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
|
||||
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_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
|
||||
------------
|
||||
@@ -82,22 +299,22 @@ Field naming
|
||||
* For look-up of eUICC specific key material (like SCP03 keys for the
|
||||
ISD-R, ECASD), pySim uses the `EID` field as lookup key.
|
||||
|
||||
As soon as the CardKeyProviderCsv finds a line (row) in your CSV where
|
||||
the ICCID or EID match, it looks for the column containing the requested
|
||||
data.
|
||||
As soon as the CardKeyProvider finds a line (row) in your CSV file
|
||||
(or database) where the ICCID or EID match, it looks for the column containing
|
||||
the requested data.
|
||||
|
||||
|
||||
ADM PIN
|
||||
~~~~~~~
|
||||
^^^^^^^
|
||||
|
||||
The `verify_adm` command will attempt to look up the `ADM1` column
|
||||
indexed by the ICCID of the SIM/UICC.
|
||||
|
||||
|
||||
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
|
||||
specifications.
|
||||
|
||||
|
||||
36
docs/conf.py
36
docs/conf.py
@@ -13,14 +13,23 @@
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
sys.path.insert(0, os.path.abspath('.')) # for local extensions (pysim_fs_sphinx, ...)
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'osmopysim-usermanual'
|
||||
copyright = '2009-2023 by Sylvain Munaut, Harald Welte, Philipp Maier, Supreeth Herle, Merlin Chlosta'
|
||||
copyright = '2009-2025 by Sylvain Munaut, Harald Welte, Philipp Maier, Supreeth Herle, Merlin Chlosta'
|
||||
author = 'Sylvain Munaut, Harald Welte, Philipp Maier, Supreeth Herle, Merlin Chlosta'
|
||||
|
||||
# PDF: Avoid that the authors list exceeds the page by inserting '\and'
|
||||
# manually as line break (https://github.com/sphinx-doc/sphinx/issues/6875)
|
||||
latex_elements = {
|
||||
"maketitle":
|
||||
r"""\author{Sylvain Munaut, Harald Welte, Philipp Maier, \and Supreeth Herle, Merlin Chlosta}
|
||||
\sphinxmaketitle
|
||||
"""
|
||||
}
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
@@ -31,7 +40,8 @@ extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinxarg.ext",
|
||||
"sphinx.ext.autosectionlabel",
|
||||
"sphinx.ext.napoleon"
|
||||
"sphinx.ext.napoleon",
|
||||
"pysim_fs_sphinx",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
@@ -56,3 +66,25 @@ html_theme = 'alabaster'
|
||||
html_static_path = ['_static']
|
||||
|
||||
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:
|
||||
|
||||
shell
|
||||
filesystem
|
||||
trace
|
||||
legacy
|
||||
smpp2sim
|
||||
@@ -48,7 +49,7 @@ pySim consists of several parts:
|
||||
sim-rest
|
||||
suci-keytool
|
||||
saip-tool
|
||||
wsrc
|
||||
smpp-ota-tool
|
||||
|
||||
|
||||
Indices and tables
|
||||
|
||||
@@ -49,7 +49,7 @@ Two modes are possible:
|
||||
Ki and OPc will be generated during each programming cycle. This means fresh keys are generated, even when the
|
||||
``--num`` remains unchanged.
|
||||
|
||||
The parameter ``--num`` specifies a card individual number. This number will be manged into the random seed so that
|
||||
The parameter ``--num`` specifies a card individual number. This number will be managed into the random seed so that
|
||||
it serves as an identifier for a particular set of randomly generated parameters.
|
||||
|
||||
In the example above the parameters ``--mcc``, and ``--mnc`` are specified as well, since they identify the GSM
|
||||
@@ -77,7 +77,7 @@ the parameter ``--type``. The following card types are supported:
|
||||
|
||||
Specifying the card reader:
|
||||
|
||||
It is most common to use ``pySim-prog`` together whith a PCSC reader. The PCSC reader number is specified via the
|
||||
It is most common to use ``pySim-prog`` together with a PCSC reader. The PCSC reader number is specified via the
|
||||
``--pcsc-device`` or ``-p`` option. However, other reader types (such as serial readers and modems) are supported. Use
|
||||
the ``--help`` option of ``pySim-prog`` for more information.
|
||||
|
||||
@@ -205,7 +205,7 @@ Specifically, pySim-read will dump the following:
|
||||
|
||||
* DF.GSM
|
||||
|
||||
* EF,IMSI
|
||||
* EF.IMSI
|
||||
* EF.GID1
|
||||
* EF.GID2
|
||||
* EF.SMSP
|
||||
|
||||
@@ -21,9 +21,9 @@ osmo-smdpp currently
|
||||
|
||||
* [by default] uses test certificates copied from GSMA SGP.26 into `./smdpp-data/certs`, assuming that your
|
||||
osmo-smdpp would be running at the host name `testsmdpplus1.example.com`. You can of course replace those
|
||||
certificates with your own, whether SGP.26 derived or part of a *private root CA* setup with mathcing eUICCs.
|
||||
certificates with your own, whether SGP.26 derived or part of a *private root CA* setup with matching eUICCs.
|
||||
* doesn't understand profile state. Any profile can always be downloaded any number of times, irrespective
|
||||
of the EID or whether it was donwloaded before. This is actually very useful for R&D and testing, as it
|
||||
of the EID or whether it was downloaded before. This is actually very useful for R&D and testing, as it
|
||||
doesn't require you to generate new profiles all the time. This logic of course is unsuitable for
|
||||
production usage.
|
||||
* doesn't perform any personalization, so the IMSI/ICCID etc. are always identical (the ones that are stored in
|
||||
@@ -40,16 +40,21 @@ osmo-smdpp currently
|
||||
Running osmo-smdpp
|
||||
------------------
|
||||
|
||||
osmo-smdpp does not have built-in TLS support as the used *twisted* framework appears to have
|
||||
problems when using the example elliptic curve certificates (both NIST and Brainpool) from GSMA.
|
||||
osmo-smdpp comes with built-in TLS support which is enabled by default. However, it is always possible to
|
||||
disable the built-in TLS support if needed.
|
||||
|
||||
In order to use osmo-smdpp without the built-in TLS support, it has to be put behind a TLS reverse proxy,
|
||||
which terminates the ES9+ HTTPS traffic from the LPA, and then forwards it as plain HTTP to osmo-smdpp.
|
||||
|
||||
NOTE: The built in TLS support in osmo-smdpp makes use of the python *twisted* framework. Older versions
|
||||
of this framework appear to have problems when using the example elliptic curve certificates (both NIST and
|
||||
Brainpool) from GSMA.
|
||||
|
||||
So in order to use it, you have to put it behind a TLS reverse proxy, which terminates the ES9+
|
||||
HTTPS from the LPA, and then forwards it as plain HTTP to osmo-smdpp.
|
||||
|
||||
nginx as TLS proxy
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If you use `nginx` as web server, you can use the following configuration snippet::
|
||||
If you chose to use `nginx` as TLS reverse proxy, you can use the following configuration snippet::
|
||||
|
||||
upstream smdpp {
|
||||
server localhost:8000;
|
||||
@@ -92,32 +97,43 @@ The `smdpp-data/upp` directory contains the UPP (Unprotected Profile Package) us
|
||||
commandline options
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Typically, you just run it without any arguments, and it will bind its plain-HTTP ES9+ interface to
|
||||
`localhost` TCP port 8000.
|
||||
Typically, you just run osmo-smdpp without any arguments, and it will bind its built-in HTTPS ES9+ interface to
|
||||
`localhost` TCP port 443. In this case an external TLS reverse proxy is not needed.
|
||||
|
||||
osmo-smdpp currently doesn't have any configuration file.
|
||||
|
||||
There are command line options for binding:
|
||||
|
||||
Bind the HTTP ES9+ to a port other than 8000::
|
||||
Bind the HTTPS ES9+ to a port other than 443::
|
||||
|
||||
./osmo-smdpp.py -p 8001
|
||||
./osmo-smdpp.py -p 8443
|
||||
|
||||
Disable the built-in TLS support and bind the plain-HTTP ES9+ to a port 8000::
|
||||
|
||||
./osmo-smdpp.py -p 8000 --nossl
|
||||
|
||||
Bind the HTTP ES9+ to a different local interface::
|
||||
|
||||
./osmo-smdpp.py -H 127.0.0.1
|
||||
./osmo-smdpp.py -H 127.0.0.2
|
||||
|
||||
DNS setup for your LPA
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The LPA must resolve `testsmdpplus1.example.com` to the IP address of your TLS proxy.
|
||||
|
||||
It must also accept the TLS certificates used by your TLS proxy.
|
||||
It must also accept the TLS certificates used by your TLS proxy. In case osmo-smdpp is used with built-in TLS support,
|
||||
it will use the certificates provided in smdpp-data.
|
||||
|
||||
NOTE: The HTTPS ES9+ interface cannot be addressed by the LPA directly via its IP address. The reason for this is that
|
||||
the included SGP.26 (DPtls) test certificates explicitly restrict the hostname to `testsmdpplus1.example.com` in the
|
||||
`X509v3 Subject Alternative Name` extension. Using a bare IP address as hostname may cause the certificate to be
|
||||
rejected by the LPA.
|
||||
|
||||
|
||||
Supported eUICC
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
If you run osmo-smdpp with the included SGP.26 certificates, you must use an eUICC with matching SGP.26
|
||||
If you run osmo-smdpp with the included SGP.26 (DPauth, DPpb) certificates, you must use an eUICC with matching SGP.26
|
||||
certificates, i.e. the EUM certificate must be signed by a SGP.26 test root CA and the eUICC certificate
|
||||
in turn must be signed by that SGP.26 EUM certificate.
|
||||
|
||||
|
||||
836
docs/put_key-tutorial.rst
Normal file
836
docs/put_key-tutorial.rst
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
267
docs/pysim_fs_sphinx.py
Normal file
267
docs/pysim_fs_sphinx.py
Normal file
@@ -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}
|
||||
@@ -67,7 +67,7 @@ Inspecting applications
|
||||
|
||||
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
|
||||
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
|
||||
::
|
||||
|
||||
@@ -68,7 +68,7 @@ Usage Examples
|
||||
|
||||
suci-tutorial
|
||||
cap-tutorial
|
||||
|
||||
put_key-tutorial
|
||||
|
||||
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,
|
||||
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
|
||||
encodable; please check your input and/or us the raw :ref:`update_record` comamdn.
|
||||
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 use the raw :ref:`update_record` command.
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
encodable; please check your input and/or us the raw :ref:`update_binary` comamdn.
|
||||
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 use the raw :ref:`update_binary` command.
|
||||
|
||||
|
||||
decode_hex
|
||||
|
||||
@@ -75,7 +75,7 @@ The response body is a JSON document, either
|
||||
#. key freshness failure
|
||||
#. unspecified card error
|
||||
|
||||
Example (succcess):
|
||||
Example (success):
|
||||
::
|
||||
|
||||
{
|
||||
|
||||
179
docs/smpp-ota-tool.rst
Normal file
179
docs/smpp-ota-tool.rst
Normal file
@@ -0,0 +1,179 @@
|
||||
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.
|
||||
|
||||
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'}))
|
||||
INFO root: ENVELOPE: d147820283818604001032548b3b400290217ff6227052000000002d02700000281516191212b0000127fa28a5bac69d3c5e9df2c7155dfdde449c826b236215566530787b30e8be5d
|
||||
INFO root: SW 9000: 027100002412b000019a551bb7c28183652de0ace6170d0e563c5e949a3ba56747fe4c1dbbef16642c
|
||||
|
||||
.. note:: for sending OTA SMS messages :ref:`smpp-ota-tool` may be used.
|
||||
|
||||
@@ -17,7 +17,7 @@ In any case, in order to operate a SUCI-enabled 5G SA network, you will have to
|
||||
#. deploy the public key on your USIMs
|
||||
#. deploy the private key on your 5GC, specifically the UDM function
|
||||
|
||||
pysim contains (int its `contrib` directory) a small utility program that can make it easy to generate
|
||||
pysim contains (in its `contrib` directory) a small utility program that can make it easy to generate
|
||||
such keys: `suci-keytool.py`
|
||||
|
||||
Generating keys
|
||||
|
||||
@@ -30,7 +30,7 @@ This guide covers the basic workflow of provisioning SIM cards with the 5G SUCI
|
||||
|
||||
For specific information on sysmocom SIM cards, refer to
|
||||
|
||||
* the `sysmoISIM-SJA5 User Manual <https://sysmocom.de/manuals/sysmoisim-sja5-manual.pdf>`__ for the curent
|
||||
* the `sysmoISIM-SJA5 User Manual <https://sysmocom.de/manuals/sysmoisim-sja5-manual.pdf>`__ for the current
|
||||
sysmoISIM-SJA5 product
|
||||
* the `sysmoISIM-SJA2 User Manual <https://sysmocom.de/manuals/sysmousim-manual.pdf>`__ for the older
|
||||
sysmoISIM-SJA2 product
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
WebSocket Remote Card (WSRC)
|
||||
============================
|
||||
|
||||
WSRC (*Web Socket Remote Card*) is a mechanism by which card readers can be made remotely available
|
||||
via a computer network. The transport mechanism is (as the name implies) a WebSocket. This transport
|
||||
method was chosen to be as firewall/NAT friendly as possible.
|
||||
|
||||
WSRC Network Architecture
|
||||
-------------------------
|
||||
|
||||
In a WSRC network, there are three major elements:
|
||||
|
||||
* The **WSRC Card Client** which exposes a locally attached smart card (usually via a Smart Card Reader)
|
||||
to a remote *WSRC Server*
|
||||
* The **WSRC Server** manges incoming connections from both *WSRC Card Clients* as well as *WSRC User Clients*
|
||||
* The **WSRC User Client** is a user application, like for example pySim-shell, which is accessing a remote
|
||||
card by connecting to the *WSRC Server* which relays the information to the selected *WSRC Card Client*
|
||||
|
||||
WSRC Protocol
|
||||
-------------
|
||||
|
||||
The WSRC protocl consits of JSON objects being sent over a websocket. The websocket communication itself
|
||||
is based on HTTP and should usually operate via TLS for security reasons.
|
||||
|
||||
The detailed protocol is currently still WIP. The plan is to document it here.
|
||||
|
||||
|
||||
pySim implementations
|
||||
---------------------
|
||||
|
||||
TBD
|
||||
462
osmo-smdpp.py
462
osmo-smdpp.py
@@ -17,28 +17,124 @@
|
||||
# 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 json
|
||||
import sys
|
||||
import argparse
|
||||
import uuid
|
||||
import os
|
||||
import functools
|
||||
from typing import Optional, Dict, List
|
||||
from pprint import pprint as pp
|
||||
|
||||
import base64
|
||||
from base64 import b64decode
|
||||
from klein import Klein
|
||||
from twisted.web.iweb import IRequest
|
||||
# asn1tools issue https://github.com/eerimoq/asn1tools/issues/194
|
||||
# must be first here
|
||||
import asn1tools
|
||||
import asn1tools.codecs.ber
|
||||
import asn1tools.codecs.der
|
||||
# do not move the code
|
||||
def fix_asn1_oid_decoding():
|
||||
fix_asn1_schema = """
|
||||
TestModule DEFINITIONS ::= BEGIN
|
||||
TestOid ::= SEQUENCE {
|
||||
oid OBJECT IDENTIFIER
|
||||
}
|
||||
END
|
||||
"""
|
||||
|
||||
from osmocom.utils import h2b, b2h, swap_nibbles
|
||||
fix_asn1_asn1 = asn1tools.compile_string(fix_asn1_schema, codec='der')
|
||||
fix_asn1_oid_string = '2.999.10'
|
||||
fix_asn1_encoded = fix_asn1_asn1.encode('TestOid', {'oid': fix_asn1_oid_string})
|
||||
fix_asn1_decoded = fix_asn1_asn1.decode('TestOid', fix_asn1_encoded)
|
||||
|
||||
import pySim.esim.rsp as rsp
|
||||
from pySim.esim import saip, PMO
|
||||
from pySim.esim.es8p import *
|
||||
from pySim.esim.x509_cert import oid, cert_policy_has_oid, cert_get_auth_key_id
|
||||
from pySim.esim.x509_cert import CertAndPrivkey, CertificateSet, cert_get_subject_key_id, VerifyError
|
||||
if (fix_asn1_decoded['oid'] != fix_asn1_oid_string):
|
||||
# ASN.1 OBJECT IDENTIFIER Decoding Issue:
|
||||
#
|
||||
# In ASN.1 BER/DER encoding, the first two arcs of an OBJECT IDENTIFIER are
|
||||
# combined into a single value: (40 * arc0) + arc1. This is encoded as a base-128
|
||||
# variable-length quantity (and commonly known as VLQ or base-128 encoding)
|
||||
# as specified in ITU-T X.690 §8.19, it can span multiple bytes if
|
||||
# the value is large.
|
||||
#
|
||||
# For arc0 = 0 or 1, arc1 must be in [0, 39]. For arc0 = 2, arc1 can be any non-negative integer.
|
||||
# All subsequent arcs (arc2, arc3, ...) are each encoded as a separate base-128 VLQ.
|
||||
#
|
||||
# The decoding bug occurs when the decoder does not properly split the first
|
||||
# subidentifier for arc0 = 2 and arc1 >= 40. Instead of decoding:
|
||||
# - arc0 = 2
|
||||
# - arc1 = (first_subidentifier - 80)
|
||||
# it may incorrectly interpret the first_subidentifier as arc0 = (first_subidentifier // 40),
|
||||
# arc1 = (first_subidentifier % 40), which is only valid for arc1 < 40.
|
||||
#
|
||||
# This patch handles it properly for all valid OBJECT IDENTIFIERs
|
||||
# with large second arcs, by applying the ASN.1 rules:
|
||||
# - if first_subidentifier < 40: arc0 = 0, arc1 = first_subidentifier
|
||||
# - elif first_subidentifier < 80: arc0 = 1, arc1 = first_subidentifier - 40
|
||||
# - else: arc0 = 2, arc1 = first_subidentifier - 80
|
||||
#
|
||||
# This problem is not uncommon, see for example https://github.com/randombit/botan/issues/4023
|
||||
|
||||
def fixed_decode_object_identifier(data, offset, end_offset):
|
||||
"""Decode ASN.1 OBJECT IDENTIFIER from bytes to dotted string, fixing large second arc handling."""
|
||||
def read_subidentifier(data, offset):
|
||||
value = 0
|
||||
while True:
|
||||
b = data[offset]
|
||||
value = (value << 7) | (b & 0x7F)
|
||||
offset += 1
|
||||
if not (b & 0x80):
|
||||
break
|
||||
return value, offset
|
||||
|
||||
subid, offset = read_subidentifier(data, offset)
|
||||
if subid < 40:
|
||||
first = 0
|
||||
second = subid
|
||||
elif subid < 80:
|
||||
first = 1
|
||||
second = subid - 40
|
||||
else:
|
||||
first = 2
|
||||
second = subid - 80
|
||||
arcs = [first, second]
|
||||
|
||||
while offset < end_offset:
|
||||
subid, offset = read_subidentifier(data, offset)
|
||||
arcs.append(subid)
|
||||
|
||||
return '.'.join(str(x) for x in arcs)
|
||||
|
||||
asn1tools.codecs.ber.decode_object_identifier = fixed_decode_object_identifier
|
||||
asn1tools.codecs.der.decode_object_identifier = fixed_decode_object_identifier
|
||||
|
||||
# test our patch
|
||||
asn1 = asn1tools.compile_string(fix_asn1_schema, codec='der')
|
||||
decoded = asn1.decode('TestOid', fix_asn1_encoded)['oid']
|
||||
assert fix_asn1_oid_string == str(decoded)
|
||||
|
||||
fix_asn1_oid_decoding()
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature # noqa: E402
|
||||
from cryptography import x509 # noqa: E402
|
||||
from cryptography.exceptions import InvalidSignature # noqa: E402
|
||||
from cryptography.hazmat.primitives import hashes # noqa: E402
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, dh # noqa: E402
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, PrivateFormat, NoEncryption, ParameterFormat # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
import json # noqa: E402
|
||||
import sys # noqa: E402
|
||||
import argparse # noqa: E402
|
||||
import uuid # noqa: E402
|
||||
import os # noqa: E402
|
||||
import functools # noqa: E402
|
||||
from typing import Optional, Dict, List # noqa: E402
|
||||
from pprint import pprint as pp # noqa: E402
|
||||
|
||||
import base64 # noqa: E402
|
||||
from base64 import b64decode # noqa: E402
|
||||
from klein import Klein # noqa: E402
|
||||
from twisted.web.iweb import IRequest # noqa: E402
|
||||
|
||||
from osmocom.utils import h2b, b2h, swap_nibbles # noqa: E402
|
||||
|
||||
import pySim.esim.rsp as rsp # noqa: E402
|
||||
from pySim.esim import saip, PMO # noqa: E402
|
||||
from pySim.esim.es8p import ProfileMetadata,UnprotectedProfilePackage,ProtectedProfilePackage,BoundProfilePackage,BspInstance # noqa: E402
|
||||
from pySim.esim.x509_cert import oid, cert_policy_has_oid, cert_get_auth_key_id # noqa: E402
|
||||
from pySim.esim.x509_cert import CertAndPrivkey, CertificateSet, cert_get_subject_key_id, VerifyError # noqa: E402
|
||||
|
||||
import logging # noqa: E402
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HACK: make this configurable
|
||||
DATA_DIR = './smdpp-data'
|
||||
@@ -54,6 +150,173 @@ def set_headers(request: IRequest):
|
||||
request.setHeader('Content-Type', 'application/json;charset=UTF-8')
|
||||
request.setHeader('X-Admin-Protocol', 'gsma/rsp/v2.1.0')
|
||||
|
||||
def validate_request_headers(request: IRequest):
|
||||
"""Validate mandatory HTTP headers according to SGP.22."""
|
||||
content_type = request.getHeader('Content-Type')
|
||||
if not content_type or not content_type.startswith('application/json'):
|
||||
raise ApiError('1.2.1', '2.1', 'Invalid Content-Type header')
|
||||
|
||||
admin_protocol = request.getHeader('X-Admin-Protocol')
|
||||
if admin_protocol and not admin_protocol.startswith('gsma/rsp/v'):
|
||||
raise ApiError('1.2.2', '2.1', 'Unsupported X-Admin-Protocol version')
|
||||
|
||||
def get_eum_certificate_variant(eum_cert) -> str:
|
||||
"""Determine EUM certificate variant by checking Certificate Policies extension.
|
||||
Returns 'O' for old variant, or 'NEW' for Ov3/A/B/C variants."""
|
||||
|
||||
try:
|
||||
cert_policies_ext = eum_cert.extensions.get_extension_for_oid(
|
||||
x509.oid.ExtensionOID.CERTIFICATE_POLICIES
|
||||
)
|
||||
|
||||
for policy in cert_policies_ext.value:
|
||||
policy_oid = policy.policy_identifier.dotted_string
|
||||
logger.debug(f"Found certificate policy: {policy_oid}")
|
||||
|
||||
if policy_oid == '2.23.146.1.2.1.2':
|
||||
logger.debug("Detected EUM certificate variant: O (old)")
|
||||
return 'O'
|
||||
elif policy_oid == '2.23.146.1.2.1.0.0.0':
|
||||
logger.debug("Detected EUM certificate variant: Ov3/A/B/C (new)")
|
||||
return 'NEW'
|
||||
except x509.ExtensionNotFound:
|
||||
logger.debug("No Certificate Policies extension found")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking certificate policies: {e}")
|
||||
|
||||
def parse_permitted_eins_from_cert(eum_cert) -> List[str]:
|
||||
"""Extract permitted IINs from EUM certificate using the appropriate method
|
||||
based on certificate variant (O vs Ov3/A/B/C).
|
||||
Returns list of permitted IINs (basically prefixes that valid EIDs must start with)."""
|
||||
|
||||
# Determine certificate variant first
|
||||
cert_variant = get_eum_certificate_variant(eum_cert)
|
||||
permitted_iins = []
|
||||
|
||||
if cert_variant == 'O':
|
||||
# Old variant - use nameConstraints extension
|
||||
permitted_iins.extend(_parse_name_constraints_eins(eum_cert))
|
||||
|
||||
else:
|
||||
# New variants (Ov3, A, B, C) - use GSMA permittedEins extension
|
||||
permitted_iins.extend(_parse_gsma_permitted_eins(eum_cert))
|
||||
|
||||
unique_iins = list(set(permitted_iins))
|
||||
|
||||
logger.debug(f"Total unique permitted IINs found: {len(unique_iins)}")
|
||||
return unique_iins
|
||||
|
||||
def _parse_gsma_permitted_eins(eum_cert) -> List[str]:
|
||||
"""Parse the GSMA permittedEins extension using correct ASN.1 structure.
|
||||
PermittedEins ::= SEQUENCE OF PrintableString
|
||||
Each string contains an IIN (Issuer Identification Number) - a prefix of valid EIDs."""
|
||||
permitted_iins = []
|
||||
|
||||
try:
|
||||
permitted_eins_oid = x509.ObjectIdentifier('2.23.146.1.2.2.0') # sgp26: 2.23.146.1.2.2.0 = ASN1:SEQUENCE:permittedEins
|
||||
|
||||
for ext in eum_cert.extensions:
|
||||
if ext.oid == permitted_eins_oid:
|
||||
logger.debug(f"Found GSMA permittedEins extension: {ext.oid}")
|
||||
|
||||
# Get the DER-encoded extension value
|
||||
ext_der = ext.value.value if hasattr(ext.value, 'value') else ext.value
|
||||
|
||||
if isinstance(ext_der, bytes):
|
||||
try:
|
||||
permitted_eins_schema = """
|
||||
PermittedEins DEFINITIONS ::= BEGIN
|
||||
PermittedEins ::= SEQUENCE OF PrintableString
|
||||
END
|
||||
"""
|
||||
decoder = asn1tools.compile_string(permitted_eins_schema)
|
||||
decoded_strings = decoder.decode('PermittedEins', ext_der)
|
||||
|
||||
for iin_string in decoded_strings:
|
||||
# Each string contains an IIN -> prefix of euicc EID
|
||||
iin_clean = iin_string.strip().upper()
|
||||
|
||||
# IINs is 8 chars per sgp22, var len according to sgp29, fortunately we don't care
|
||||
if (len(iin_clean) == 8 and
|
||||
all(c in '0123456789ABCDEF' for c in iin_clean) and
|
||||
len(iin_clean) % 2 == 0):
|
||||
permitted_iins.append(iin_clean)
|
||||
logger.debug(f"Found permitted IIN (GSMA): {iin_clean}")
|
||||
else:
|
||||
logger.debug(f"Invalid IIN format: {iin_string} (cleaned: {iin_clean})")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing GSMA permittedEins extension: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error accessing GSMA certificate extensions: {e}")
|
||||
|
||||
return permitted_iins
|
||||
|
||||
|
||||
def _parse_name_constraints_eins(eum_cert) -> List[str]:
|
||||
"""Parse permitted IINs from nameConstraints extension (variant O)."""
|
||||
permitted_iins = []
|
||||
|
||||
try:
|
||||
# Look for nameConstraints extension
|
||||
name_constraints_ext = eum_cert.extensions.get_extension_for_oid(
|
||||
x509.oid.ExtensionOID.NAME_CONSTRAINTS
|
||||
)
|
||||
|
||||
name_constraints = name_constraints_ext.value
|
||||
|
||||
# Check permittedSubtrees for IIN constraints
|
||||
if name_constraints.permitted_subtrees:
|
||||
for subtree in name_constraints.permitted_subtrees:
|
||||
|
||||
if isinstance(subtree, x509.DirectoryName):
|
||||
for attribute in subtree.value:
|
||||
# IINs for O in serialNumber
|
||||
if attribute.oid == x509.oid.NameOID.SERIAL_NUMBER:
|
||||
serial_value = attribute.value.upper()
|
||||
# sgp22 8, sgp29 var len, fortunately we don't care
|
||||
if (len(serial_value) == 8 and
|
||||
all(c in '0123456789ABCDEF' for c in serial_value) and
|
||||
len(serial_value) % 2 == 0):
|
||||
permitted_iins.append(serial_value)
|
||||
logger.debug(f"Found permitted IIN (nameConstraints/DN): {serial_value}")
|
||||
|
||||
except x509.ExtensionNotFound:
|
||||
logger.debug("No nameConstraints extension found")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing nameConstraints: {e}")
|
||||
|
||||
return permitted_iins
|
||||
|
||||
|
||||
def validate_eid_range(eid: str, eum_cert) -> bool:
|
||||
"""Validate that EID is within the permitted EINs of the EUM certificate."""
|
||||
if not eid or len(eid) != 32:
|
||||
logger.debug(f"Invalid EID format: {eid}")
|
||||
return False
|
||||
|
||||
try:
|
||||
permitted_eins = parse_permitted_eins_from_cert(eum_cert)
|
||||
|
||||
if not permitted_eins:
|
||||
logger.debug("Warning: No permitted EINs found in EUM certificate")
|
||||
return False
|
||||
|
||||
eid_normalized = eid.upper()
|
||||
logger.debug(f"Validating EID {eid_normalized} against {len(permitted_eins)} permitted EINs")
|
||||
|
||||
for permitted_ein in permitted_eins:
|
||||
if eid_normalized.startswith(permitted_ein):
|
||||
logger.debug(f"EID {eid_normalized} matches permitted EIN {permitted_ein}")
|
||||
return True
|
||||
|
||||
logger.debug(f"EID {eid_normalized} is not in any permitted EIN list")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error validating EID: {e}")
|
||||
return False
|
||||
|
||||
def build_status_code(subject_code: str, reason_code: str, subject_id: Optional[str], message: Optional[str]) -> Dict:
|
||||
r = {'subjectCode': subject_code, 'reasonCode': reason_code }
|
||||
if subject_id:
|
||||
@@ -72,12 +335,6 @@ def build_resp_header(js: dict, status: str = 'Executed-Success', status_code_da
|
||||
if status_code_data:
|
||||
js['header']['functionExecutionStatus']['statusCodeData'] = status_code_data
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, PrivateFormat, NoEncryption
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography import x509
|
||||
|
||||
def ecdsa_tr03111_to_dss(sig: bytes) -> bytes:
|
||||
"""convert an ECDSA signature from BSI TR-03111 format to DER: first get long integers; then encode those."""
|
||||
@@ -125,22 +382,36 @@ class SmDppHttpServer:
|
||||
def ci_get_cert_for_pkid(self, ci_pkid: bytes) -> Optional[x509.Certificate]:
|
||||
"""Find CI certificate for given key identifier."""
|
||||
for cert in self.ci_certs:
|
||||
print("cert: %s" % cert)
|
||||
logger.debug("cert: %s" % cert)
|
||||
subject_exts = list(filter(lambda x: isinstance(x.value, x509.SubjectKeyIdentifier), cert.extensions))
|
||||
print(subject_exts)
|
||||
logger.debug(subject_exts)
|
||||
subject_pkid = subject_exts[0].value
|
||||
print(subject_pkid)
|
||||
logger.debug(subject_pkid)
|
||||
if subject_pkid and subject_pkid.key_identifier == ci_pkid:
|
||||
return cert
|
||||
return None
|
||||
|
||||
def __init__(self, server_hostname: str, ci_certs_path: str, use_brainpool: bool = False):
|
||||
def validate_certificate_chain_for_verification(self, euicc_ci_pkid_list: List[bytes]) -> bool:
|
||||
"""Validate that SM-DP+ has valid certificate chains for the given CI PKIDs."""
|
||||
for ci_pkid in euicc_ci_pkid_list:
|
||||
ci_cert = self.ci_get_cert_for_pkid(ci_pkid)
|
||||
if ci_cert:
|
||||
# Check if our DPauth certificate chains to this CI
|
||||
try:
|
||||
cs = CertificateSet(ci_cert)
|
||||
cs.verify_cert_chain(self.dp_auth.cert)
|
||||
return True
|
||||
except VerifyError:
|
||||
continue
|
||||
return False
|
||||
|
||||
def __init__(self, server_hostname: str, ci_certs_path: str, common_cert_path: str, use_brainpool: bool = False, in_memory: bool = False):
|
||||
self.server_hostname = server_hostname
|
||||
self.upp_dir = os.path.realpath(os.path.join(DATA_DIR, 'upp'))
|
||||
self.ci_certs = self.load_certs_from_path(ci_certs_path)
|
||||
# load DPauth cert + key
|
||||
self.dp_auth = CertAndPrivkey(oid.id_rspRole_dp_auth_v2)
|
||||
cert_dir = os.path.join(DATA_DIR, 'certs')
|
||||
cert_dir = common_cert_path
|
||||
if use_brainpool:
|
||||
self.dp_auth.cert_from_der_file(os.path.join(cert_dir, 'DPauth', 'CERT_S_SM_DPauth_ECDSA_BRP.der'))
|
||||
self.dp_auth.privkey_from_pem_file(os.path.join(cert_dir, 'DPauth', 'SK_S_SM_DPauth_ECDSA_BRP.pem'))
|
||||
@@ -155,7 +426,15 @@ class SmDppHttpServer:
|
||||
else:
|
||||
self.dp_pb.cert_from_der_file(os.path.join(cert_dir, 'DPpb', 'CERT_S_SM_DPpb_ECDSA_NIST.der'))
|
||||
self.dp_pb.privkey_from_pem_file(os.path.join(cert_dir, 'DPpb', 'SK_S_SM_DPpb_ECDSA_NIST.pem'))
|
||||
self.rss = rsp.RspSessionStore(os.path.join(DATA_DIR, "sm-dp-sessions"))
|
||||
if in_memory:
|
||||
self.rss = rsp.RspSessionStore(in_memory=True)
|
||||
logger.info("Using in-memory session storage")
|
||||
else:
|
||||
# Use different session database files for BRP and NIST to avoid file locking during concurrent runs
|
||||
session_db_suffix = "BRP" if use_brainpool else "NIST"
|
||||
db_path = os.path.join(DATA_DIR, f"sm-dp-sessions-{session_db_suffix}")
|
||||
self.rss = rsp.RspSessionStore(filename=db_path, in_memory=False)
|
||||
logger.info(f"Using file-based session storage: {db_path}")
|
||||
|
||||
@app.handle_errors(ApiError)
|
||||
def handle_apierror(self, request: IRequest, failure):
|
||||
@@ -179,11 +458,10 @@ class SmDppHttpServer:
|
||||
functionality, such as JSON decoding/encoding and debug-printing."""
|
||||
@functools.wraps(func)
|
||||
def _api_wrapper(self, request: IRequest):
|
||||
# TODO: evaluate User-Agent + X-Admin-Protocol header
|
||||
# TODO: reject any non-JSON Content-type
|
||||
validate_request_headers(request)
|
||||
|
||||
content = json.loads(request.content.read())
|
||||
print("Rx JSON: %s" % json.dumps(content))
|
||||
logger.debug("Rx JSON: %s" % json.dumps(content))
|
||||
set_headers(request)
|
||||
|
||||
output = func(self, request, content)
|
||||
@@ -191,7 +469,7 @@ class SmDppHttpServer:
|
||||
return ''
|
||||
|
||||
build_resp_header(output)
|
||||
print("Tx JSON: %s" % json.dumps(output))
|
||||
logger.debug("Tx JSON: %s" % json.dumps(output))
|
||||
return json.dumps(output)
|
||||
return _api_wrapper
|
||||
|
||||
@@ -210,7 +488,7 @@ class SmDppHttpServer:
|
||||
|
||||
euiccInfo1_bin = b64decode(content['euiccInfo1'])
|
||||
euiccInfo1 = rsp.asn1.decode('EUICCInfo1', euiccInfo1_bin)
|
||||
print("Rx euiccInfo1: %s" % euiccInfo1)
|
||||
logger.debug("Rx euiccInfo1: %s" % euiccInfo1)
|
||||
#euiccInfo1['svn']
|
||||
|
||||
# TODO: If euiccCiPKIdListForSigningV3 is present ...
|
||||
@@ -218,6 +496,12 @@ class SmDppHttpServer:
|
||||
pkid_list = euiccInfo1['euiccCiPKIdListForSigning']
|
||||
if 'euiccCiPKIdListForSigningV3' in euiccInfo1:
|
||||
pkid_list = pkid_list + euiccInfo1['euiccCiPKIdListForSigningV3']
|
||||
|
||||
# Validate that SM-DP+ supports certificate chains for verification
|
||||
verification_pkid_list = euiccInfo1.get('euiccCiPKIdListForVerification', [])
|
||||
if verification_pkid_list and not self.validate_certificate_chain_for_verification(verification_pkid_list):
|
||||
raise ApiError('8.8.4', '3.7', 'The SM-DP+ has no CERT.DPauth.SIG which chains to one of the eSIM CA Root CA Certificate with a Public Key supported by the eUICC')
|
||||
|
||||
# verify it supports one of the keys indicated by euiccCiPKIdListForSigning
|
||||
ci_cert = None
|
||||
for x in pkid_list:
|
||||
@@ -232,13 +516,6 @@ class SmDppHttpServer:
|
||||
if not ci_cert:
|
||||
raise ApiError('8.8.2', '3.1', 'None of the proposed Public Key Identifiers is supported by the SM-DP+')
|
||||
|
||||
# TODO: Determine the set of CERT.DPauth.SIG that satisfy the following criteria:
|
||||
# * Part of a certificate chain ending at one of the eSIM CA RootCA Certificate, whose Public Keys is
|
||||
# supported by the eUICC (indicated by euiccCiPKIdListForVerification).
|
||||
# * Using a certificate chain that the eUICC and the LPA both support:
|
||||
#euiccInfo1['euiccCiPKIdListForVerification']
|
||||
# raise ApiError('8.8.4', '3.7', 'The SM-DP+ has no CERT.DPauth.SIG which chains to one of the eSIM CA Root CA CErtificate with a Public Key supported by the eUICC')
|
||||
|
||||
# Generate a TransactionID which is used to identify the ongoing RSP session. The TransactionID
|
||||
# SHALL be unique within the scope and lifetime of each SM-DP+.
|
||||
transactionId = uuid.uuid4().hex.upper()
|
||||
@@ -254,9 +531,9 @@ class SmDppHttpServer:
|
||||
'serverAddress': self.server_hostname,
|
||||
'serverChallenge': serverChallenge,
|
||||
}
|
||||
print("Tx serverSigned1: %s" % serverSigned1)
|
||||
logger.debug("Tx serverSigned1: %s" % serverSigned1)
|
||||
serverSigned1_bin = rsp.asn1.encode('ServerSigned1', serverSigned1)
|
||||
print("Tx serverSigned1: %s" % rsp.asn1.decode('ServerSigned1', serverSigned1_bin))
|
||||
logger.debug("Tx serverSigned1: %s" % rsp.asn1.decode('ServerSigned1', serverSigned1_bin))
|
||||
output = {}
|
||||
output['serverSigned1'] = b64encode2str(serverSigned1_bin)
|
||||
|
||||
@@ -285,7 +562,7 @@ class SmDppHttpServer:
|
||||
|
||||
authenticateServerResp_bin = b64decode(content['authenticateServerResponse'])
|
||||
authenticateServerResp = rsp.asn1.decode('AuthenticateServerResponse', authenticateServerResp_bin)
|
||||
print("Rx %s: %s" % authenticateServerResp)
|
||||
logger.debug("Rx %s: %s" % authenticateServerResp)
|
||||
if authenticateServerResp[0] == 'authenticateResponseError':
|
||||
r_err = authenticateServerResp[1]
|
||||
#r_err['transactionId']
|
||||
@@ -336,10 +613,12 @@ class SmDppHttpServer:
|
||||
if not self._ecdsa_verify(euicc_cert, euiccSignature1_bin, euiccSigned1_bin):
|
||||
raise ApiError('8.1', '6.1', 'Verification failed (euiccSignature1 over euiccSigned1)')
|
||||
|
||||
# TODO: verify EID of eUICC cert is within permitted range of EUM cert
|
||||
|
||||
ss.eid = ss.euicc_cert.subject.get_attributes_for_oid(x509.oid.NameOID.SERIAL_NUMBER)[0].value
|
||||
print("EID (from eUICC cert): %s" % ss.eid)
|
||||
logger.debug("EID (from eUICC cert): %s" % ss.eid)
|
||||
|
||||
# Verify EID is within permitted range of EUM certificate
|
||||
if not validate_eid_range(ss.eid, eum_cert):
|
||||
raise ApiError('8.1.4', '6.1', 'EID is not within the permitted range of the EUM certificate')
|
||||
|
||||
# Verify that the serverChallenge attached to the ongoing RSP session matches the
|
||||
# serverChallenge returned by the eUICC. Otherwise, the SM-DP+ SHALL return a status code "eUICC -
|
||||
@@ -350,6 +629,7 @@ class SmDppHttpServer:
|
||||
# If ctxParams1 contains a ctxParamsForCommonAuthentication data object, the SM-DP+ Shall [...]
|
||||
# TODO: We really do a very simplistic job here, this needs to be properly implemented later,
|
||||
# considering all the various cases, profile state, etc.
|
||||
iccid_str = None
|
||||
if euiccSigned1['ctxParams1'][0] == 'ctxParamsForCommonAuthentication':
|
||||
cpca = euiccSigned1['ctxParams1'][1]
|
||||
matchingId = cpca.get('matchingId', None)
|
||||
@@ -372,7 +652,7 @@ class SmDppHttpServer:
|
||||
# there's currently no other option in the ctxParams1 choice, so this cannot happen
|
||||
raise ApiError('1.3.1', '2.2', 'ctxParams1 missing mandatory ctxParamsForCommonAuthentication')
|
||||
|
||||
# FIXME: we actually want to perform the profile binding herr, and read the profile metadat from the profile
|
||||
# FIXME: we actually want to perform the profile binding herr, and read the profile metadata from the profile
|
||||
|
||||
# Put together profileMetadata + _bin
|
||||
ss.profileMetadata = ProfileMetadata(iccid_bin=h2b(swap_nibbles(iccid_str)), spn="OsmocomSPN", profile_name=matchingId)
|
||||
@@ -414,7 +694,7 @@ class SmDppHttpServer:
|
||||
|
||||
prepDownloadResp_bin = b64decode(content['prepareDownloadResponse'])
|
||||
prepDownloadResp = rsp.asn1.decode('PrepareDownloadResponse', prepDownloadResp_bin)
|
||||
print("Rx %s: %s" % prepDownloadResp)
|
||||
logger.debug("Rx %s: %s" % prepDownloadResp)
|
||||
|
||||
if prepDownloadResp[0] == 'downloadResponseError':
|
||||
r_err = prepDownloadResp[1]
|
||||
@@ -436,37 +716,37 @@ class SmDppHttpServer:
|
||||
|
||||
# store otPK.EUICC.ECKA in session state
|
||||
ss.euicc_otpk = euiccSigned2['euiccOtpk']
|
||||
print("euiccOtpk: %s" % (b2h(ss.euicc_otpk)))
|
||||
logger.debug("euiccOtpk: %s" % (b2h(ss.euicc_otpk)))
|
||||
|
||||
# Generate a one-time ECKA key pair (ot{PK,SK}.DP.ECKA) using the curve indicated by the Key Parameter
|
||||
# Reference value of CERT.DPpb.ECDDSA
|
||||
print("curve = %s" % self.dp_pb.get_curve())
|
||||
logger.debug("curve = %s" % self.dp_pb.get_curve())
|
||||
ss.smdp_ot = ec.generate_private_key(self.dp_pb.get_curve())
|
||||
# extract the public key in (hopefully) the right format for the ES8+ interface
|
||||
ss.smdp_otpk = ss.smdp_ot.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
|
||||
print("smdpOtpk: %s" % b2h(ss.smdp_otpk))
|
||||
print("smdpOtsk: %s" % b2h(ss.smdp_ot.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())))
|
||||
logger.debug("smdpOtpk: %s" % b2h(ss.smdp_otpk))
|
||||
logger.debug("smdpOtsk: %s" % b2h(ss.smdp_ot.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())))
|
||||
|
||||
ss.host_id = b'mahlzeit'
|
||||
|
||||
# Generate Session Keys using the CRT, otPK.eUICC.ECKA and otSK.DP.ECKA according to annex G
|
||||
euicc_public_key = ec.EllipticCurvePublicKey.from_encoded_point(ss.smdp_ot.curve, ss.euicc_otpk)
|
||||
ss.shared_secret = ss.smdp_ot.exchange(ec.ECDH(), euicc_public_key)
|
||||
print("shared_secret: %s" % b2h(ss.shared_secret))
|
||||
logger.debug("shared_secret: %s" % b2h(ss.shared_secret))
|
||||
|
||||
# TODO: Check if this order requires a Confirmation Code verification
|
||||
|
||||
# Perform actual protection + binding of profile package (or return pre-bound one)
|
||||
with open(os.path.join(self.upp_dir, ss.matchingId)+'.der', 'rb') as f:
|
||||
upp = UnprotectedProfilePackage.from_der(f.read(), metadata=ss.profileMetadata)
|
||||
# HACK: Use empty PPP as we're still debuggin the configureISDP step, and we want to avoid
|
||||
# HACK: Use empty PPP as we're still debugging the configureISDP step, and we want to avoid
|
||||
# cluttering the log with stuff happening after the failure
|
||||
#upp = UnprotectedProfilePackage.from_der(b'', metadata=ss.profileMetadata)
|
||||
if False:
|
||||
# Use random keys
|
||||
bpp = BoundProfilePackage.from_upp(upp)
|
||||
else:
|
||||
# Use sesssion keys
|
||||
# Use session keys
|
||||
ppp = ProtectedProfilePackage.from_upp(upp, BspInstance(b'\x00'*16, b'\x11'*16, b'\x22'*16))
|
||||
bpp = BoundProfilePackage.from_ppp(ppp)
|
||||
|
||||
@@ -486,22 +766,22 @@ class SmDppHttpServer:
|
||||
request.setResponseCode(204)
|
||||
pendingNotification_bin = b64decode(content['pendingNotification'])
|
||||
pendingNotification = rsp.asn1.decode('PendingNotification', pendingNotification_bin)
|
||||
print("Rx %s: %s" % pendingNotification)
|
||||
logger.debug("Rx %s: %s" % pendingNotification)
|
||||
if pendingNotification[0] == 'profileInstallationResult':
|
||||
profileInstallRes = pendingNotification[1]
|
||||
pird = profileInstallRes['profileInstallationResultData']
|
||||
transactionId = b2h(pird['transactionId'])
|
||||
ss = self.rss.get(transactionId, None)
|
||||
if ss is None:
|
||||
print("Unable to find session for transactionId")
|
||||
return
|
||||
logger.warning(f"Unable to find session for transactionId: {transactionId}")
|
||||
return None # Will return HTTP 204 with empty body
|
||||
profileInstallRes['euiccSignPIR']
|
||||
# TODO: use original data, don't re-encode?
|
||||
pird_bin = rsp.asn1.encode('ProfileInstallationResultData', pird)
|
||||
# verify eUICC signature
|
||||
if not self._ecdsa_verify(ss.euicc_cert, profileInstallRes['euiccSignPIR'], pird_bin):
|
||||
raise Exception('ECDSA signature verification failed on notification')
|
||||
print("Profile Installation Final Result: ", pird['finalResult'])
|
||||
logger.debug("Profile Installation Final Result: %s", pird['finalResult'])
|
||||
# remove session state
|
||||
del self.rss[transactionId]
|
||||
elif pendingNotification[0] == 'otherSignedNotification':
|
||||
@@ -526,7 +806,7 @@ class SmDppHttpServer:
|
||||
iccid = other_notif.get('iccid', None)
|
||||
if iccid:
|
||||
iccid = swap_nibbles(b2h(iccid))
|
||||
print("handleNotification: EID %s: %s of %s" % (eid, pmo, iccid))
|
||||
logger.debug("handleNotification: EID %s: %s of %s" % (eid, pmo, iccid))
|
||||
else:
|
||||
raise ValueError(pendingNotification)
|
||||
|
||||
@@ -539,7 +819,7 @@ class SmDppHttpServer:
|
||||
@rsp_api_wrapper
|
||||
def cancelSession(self, request: IRequest, content: dict) -> dict:
|
||||
"""See ES9+ CancelSession in SGP.22 Section 5.6.5"""
|
||||
print("Rx JSON: %s" % content)
|
||||
logger.debug("Rx JSON: %s" % content)
|
||||
transactionId = content['transactionId']
|
||||
|
||||
# Verify that the received transactionId is known and relates to an ongoing RSP session
|
||||
@@ -549,7 +829,7 @@ class SmDppHttpServer:
|
||||
|
||||
cancelSessionResponse_bin = b64decode(content['cancelSessionResponse'])
|
||||
cancelSessionResponse = rsp.asn1.decode('CancelSessionResponse', cancelSessionResponse_bin)
|
||||
print("Rx %s: %s" % cancelSessionResponse)
|
||||
logger.debug("Rx %s: %s" % cancelSessionResponse)
|
||||
|
||||
if cancelSessionResponse[0] == 'cancelSessionResponseError':
|
||||
# FIXME: print some error
|
||||
@@ -581,15 +861,53 @@ class SmDppHttpServer:
|
||||
|
||||
def main(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-H", "--host", help="Host/IP to bind HTTP to", default="localhost")
|
||||
parser.add_argument("-p", "--port", help="TCP port to bind HTTP to", default=8000)
|
||||
#parser.add_argument("-v", "--verbose", help="increase output verbosity", action='count', default=0)
|
||||
|
||||
parser.add_argument("-H", "--host", help="Host/IP to bind HTTP(S) to", default="localhost")
|
||||
parser.add_argument("-p", "--port", help="TCP port to bind HTTP(S) to", default=443)
|
||||
parser.add_argument("-c", "--certdir", help=f"cert subdir relative to {DATA_DIR}", default="certs")
|
||||
parser.add_argument("-s", "--nossl", help="disable built in SSL/TLS support", action='store_true', default=False)
|
||||
parser.add_argument("-v", "--verbose", help="dump more raw info", action='store_true', default=False)
|
||||
parser.add_argument("-b", "--brainpool", help="Use Brainpool curves instead of NIST",
|
||||
action='store_true', default=False)
|
||||
parser.add_argument("-m", "--in-memory", help="Use ephermal in-memory session storage (for concurrent runs)",
|
||||
action='store_true', default=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
hs = SmDppHttpServer(HOSTNAME, os.path.join(DATA_DIR, 'certs', 'CertificateIssuer'), use_brainpool=False)
|
||||
#hs.app.run(endpoint_description="ssl:port=8000:dhParameters=dh_param_2048.pem")
|
||||
hs.app.run(args.host, args.port)
|
||||
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
|
||||
|
||||
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)
|
||||
if(args.nossl):
|
||||
hs.app.run(args.host, args.port)
|
||||
else:
|
||||
curve_type = 'BRP' if args.brainpool else 'NIST'
|
||||
cert_derpath = Path(common_cert_path) / 'DPtls' / f'CERT_S_SM_DP_TLS_{curve_type}.der'
|
||||
cert_pempath = Path(common_cert_path) / 'DPtls' / f'CERT_S_SM_DP_TLS_{curve_type}.pem'
|
||||
cert_skpath = Path(common_cert_path) / 'DPtls' / f'SK_S_SM_DP_TLS_{curve_type}.pem'
|
||||
dhparam_path = Path(common_cert_path) / "dhparam2048.pem"
|
||||
if not dhparam_path.exists():
|
||||
print("Generating dh params, this takes a few seconds..")
|
||||
# Generate DH parameters with 2048-bit key size and generator 2
|
||||
parameters = dh.generate_parameters(generator=2, key_size=2048)
|
||||
pem_data = parameters.parameter_bytes(encoding=Encoding.PEM,format=ParameterFormat.PKCS3)
|
||||
with open(dhparam_path, 'wb') as file:
|
||||
file.write(pem_data)
|
||||
print("DH params created successfully")
|
||||
|
||||
if not cert_pempath.exists():
|
||||
print("Translating tls server cert from DER to PEM..")
|
||||
with open(cert_derpath, 'rb') as der_file:
|
||||
der_cert_data = der_file.read()
|
||||
|
||||
cert = x509.load_der_x509_certificate(der_cert_data)
|
||||
pem_cert = cert.public_bytes(Encoding.PEM) #.decode('utf-8')
|
||||
|
||||
with open(cert_pempath, 'wb') as pem_file:
|
||||
pem_file.write(pem_cert)
|
||||
|
||||
SERVER_STRING = f'ssl:{args.port}:privateKey={cert_skpath}:certKey={cert_pempath}:dhParameters={dhparam_path}'
|
||||
print(SERVER_STRING)
|
||||
|
||||
hs.app.run(host=HOSTNAME, port=args.port, endpoint_description=SERVER_STRING)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv)
|
||||
|
||||
154
ota_test.py
154
ota_test.py
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
from pySim.ota import *
|
||||
from pySim.sms import SMS_SUBMIT, SMS_DELIVER, AddressField
|
||||
from pySim.utils import h2b, h2b
|
||||
|
||||
# pre-defined SPI values for use in test cases below
|
||||
SPI_CC_POR_CIPHERED_CC = {
|
||||
'counter':'no_counter',
|
||||
'ciphering':True,
|
||||
'rc_cc_ds': 'cc',
|
||||
'por_in_submit':False,
|
||||
'por_shall_be_ciphered':True,
|
||||
'por_rc_cc_ds': 'cc',
|
||||
'por': 'por_required'
|
||||
}
|
||||
|
||||
SPI_CC_POR_UNCIPHERED_CC = {
|
||||
'counter':'no_counter',
|
||||
'ciphering':True,
|
||||
'rc_cc_ds': 'cc',
|
||||
'por_in_submit':False,
|
||||
'por_shall_be_ciphered':False,
|
||||
'por_rc_cc_ds': 'cc',
|
||||
'por': 'por_required'
|
||||
}
|
||||
|
||||
SPI_CC_POR_UNCIPHERED_NOCC = {
|
||||
'counter':'no_counter',
|
||||
'ciphering':True,
|
||||
'rc_cc_ds': 'cc',
|
||||
'por_in_submit':False,
|
||||
'por_shall_be_ciphered':False,
|
||||
'por_rc_cc_ds': 'no_rc_cc_ds',
|
||||
'por': 'por_required'
|
||||
}
|
||||
|
||||
# SJA5 SAMPLE cards provisioned by execute_ipr.py
|
||||
OTA_KEYSET_SJA5_SAMPLES = OtaKeyset(algo_crypt='triple_des_cbc2', kic_idx=3,
|
||||
algo_auth='triple_des_cbc2', kid_idx=3,
|
||||
kic=h2b('300102030405060708090a0b0c0d0e0f'),
|
||||
kid=h2b('301102030405060708090a0b0c0d0e0f'))
|
||||
|
||||
OTA_KEYSET_SJA5_AES128 = OtaKeyset(algo_crypt='aes_cbc', kic_idx=2,
|
||||
algo_auth='aes_cmac', kid_idx=2,
|
||||
kic=h2b('200102030405060708090a0b0c0d0e0f'),
|
||||
kid=h2b('201102030405060708090a0b0c0d0e0f'))
|
||||
|
||||
# TS.48 profile on sysmoEUICC1-C2G
|
||||
OTA_KEYSET_C2G_AES128 = OtaKeyset(algo_crypt='aes_cbc', kic_idx=2,
|
||||
algo_auth='aes_cmac', kid_idx=2,
|
||||
kic=h2b('66778899AABBCCDD1122334455EEFF10'),
|
||||
kid=h2b('112233445566778899AABBCCDDEEFF10'))
|
||||
|
||||
|
||||
# ISD-R on sysmoEUICC1-C2G
|
||||
OTA_KEYSET_C2G_AES128_ISDR = OtaKeyset(algo_crypt='aes_cbc', kic_idx=1,
|
||||
algo_auth='aes_cmac', kid_idx=1,
|
||||
kic=h2b('B52F9C5938D1C19ED73E1AE772937FD7'),
|
||||
kid=h2b('3BC696ACD1EEC95A6624F7330D22FC81'))
|
||||
|
||||
# ISD-A on sysmoEUICC1-C2G
|
||||
OTA_KEYSET_C2G_AES128_ISDA = OtaKeyset(algo_crypt='aes_cbc', kic_idx=1,
|
||||
algo_auth='aes_cmac', kid_idx=1,
|
||||
kic=h2b('8DAAD1DAAA8D7C9000E3BBED8B7556E7'),
|
||||
kid=h2b('5392D503AE050DDEAF81AFAEFF275A2B'))
|
||||
|
||||
# TODO: AES192
|
||||
# TODO: AES256
|
||||
|
||||
testcases = [
|
||||
{
|
||||
'name': '3DES-SJA5-CIPHERED-CC',
|
||||
'ota_keyset': OTA_KEYSET_SJA5_SAMPLES,
|
||||
'spi': SPI_CC_POR_CIPHERED_CC,
|
||||
'request': {
|
||||
'apdu': b'\x00\xa4\x00\x04\x02\x3f\x00',
|
||||
'encoded_cmd': '00201506193535b00011ae733256918d050b87c94fbfe12e4dc402f262c41cf67f2f',
|
||||
'encoded_tpdu': '400881214365877ff6227052000000000302700000201506193535b00011ae733256918d050b87c94fbfe12e4dc402f262c41cf67f2f',
|
||||
},
|
||||
'response': {
|
||||
'encoded_resp': '027100001c12b000118bb989492c632529326a2f4681feb37c825bc9021c9f6d0b',
|
||||
}
|
||||
}, {
|
||||
'name': '3DES-SJA5-UNCIPHERED-CC',
|
||||
'ota_keyset': OTA_KEYSET_SJA5_SAMPLES,
|
||||
'spi': SPI_CC_POR_UNCIPHERED_CC,
|
||||
'request': {
|
||||
'apdu': b'\x00\xa4\x00\x04\x02\x3f\x00',
|
||||
'encoded_cmd': '00201506093535b00011c49ac91ab8159ba5b83a54fb6385e0a5e31694f8b215fafc',
|
||||
'encoded_tpdu': '400881214365877ff6227052000000000302700000201506093535b00011c49ac91ab8159ba5b83a54fb6385e0a5e31694f8b215fafc',
|
||||
},
|
||||
'response': {
|
||||
'encoded_resp': '027100001612b0001100000000000000b5bcd6353a421fae016132',
|
||||
}
|
||||
}, {
|
||||
'name': '3DES-SJA5-UNCIPHERED-NOCC',
|
||||
'ota_keyset': OTA_KEYSET_SJA5_SAMPLES,
|
||||
'spi': SPI_CC_POR_UNCIPHERED_NOCC,
|
||||
'request': {
|
||||
'apdu': b'\x00\xa4\x00\x04\x02\x3f\x00',
|
||||
'encoded_cmd': '00201506013535b000113190be334900f52b025f3f7eddfe868e96ebf310023b7769',
|
||||
'encoded_tpdu': '400881214365877ff6227052000000000302700000201506013535b000113190be334900f52b025f3f7eddfe868e96ebf310023b7769',
|
||||
},
|
||||
'response': {
|
||||
'encoded_resp': '027100000e0ab0001100000000000000016132',
|
||||
}
|
||||
}, {
|
||||
'name': 'AES128-C2G-CIPHERED-CC',
|
||||
'ota_keyset': OTA_KEYSET_C2G_AES128_ISDR,
|
||||
'spi': SPI_CC_POR_CIPHERED_CC,
|
||||
'request': {
|
||||
#'apdu': b'\x00\xa4\x00\x04\x02\x3f\x00',
|
||||
'apdu': h2b('80ec800300'),
|
||||
'encoded_cmd': '00281506192222b00011e87cceebb2d93083011ce294f93fc4d8de80da1abae8c37ca3e72ec4432e5058',
|
||||
'encoded_tpdu': '400881214365877ff6227052000000000302700000281506192222b00011e87cceebb2d93083011ce294f93fc4d8de80da1abae8c37ca3e72ec4432e5058',
|
||||
},
|
||||
'response': {
|
||||
'encoded_resp': '027100002412b00011ebc6b497e2cad7aedf36ace0e3a29b38853f0fe9ccde81913be5702b73abce1f',
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
for t in testcases:
|
||||
print()
|
||||
print("==== TESTCASE: %s" % t['name'])
|
||||
od = t['ota_keyset']
|
||||
|
||||
# RAM: B00000
|
||||
# SIM RFM: B00010
|
||||
# USIM RFM: B00011
|
||||
# ISD-R: 000001
|
||||
# ECASD: 000002
|
||||
tar = h2b('000001')
|
||||
|
||||
dialect = OtaDialectSms()
|
||||
outp = dialect.encode_cmd(od, tar, t['spi'], apdu=t['request']['apdu'])
|
||||
print("result: %s" % b2h(outp))
|
||||
#assert(b2h(outp) == t['request']['encoded_cmd'])
|
||||
|
||||
with_udh = b'\x02\x70\x00' + outp
|
||||
print("with_udh: %s" % b2h(with_udh))
|
||||
|
||||
|
||||
# processing of the response from the card
|
||||
da = AddressField('12345678', 'unknown', 'isdn_e164')
|
||||
#tpdu = SMS_SUBMIT(tp_udhi=True, tp_mr=0x23, tp_da=da, tp_pid=0x7F, tp_dcs=0xF6, tp_udl=3, tp_ud=with_udh)
|
||||
tpdu = SMS_DELIVER(tp_udhi=True, tp_oa=da, tp_pid=0x7F, tp_dcs=0xF6, tp_scts=h2b('22705200000000'), tp_udl=3, tp_ud=with_udh)
|
||||
print("TPDU: %s" % tpdu)
|
||||
print("tpdu: %s" % b2h(tpdu.to_bytes()))
|
||||
#assert(b2h(tpdu.to_bytes()) == t['request']['encoded_tpdu'])
|
||||
|
||||
r = dialect.decode_resp(od, t['spi'], t['response']['encoded_resp'])
|
||||
print("RESP: ", r)
|
||||
@@ -27,7 +27,6 @@
|
||||
import hashlib
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
@@ -44,6 +43,11 @@ from pySim.legacy.ts_51_011 import EF
|
||||
from pySim.card_handler 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():
|
||||
|
||||
@@ -185,6 +189,7 @@ def parse_options():
|
||||
default=False, action="store_true")
|
||||
parser.add_argument("--card_handler", dest="card_handler_config", metavar="FILE",
|
||||
help="Use automatic card handling machine")
|
||||
parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
|
||||
|
||||
options = parser.parse_args()
|
||||
|
||||
@@ -430,7 +435,7 @@ def gen_parameters(opts):
|
||||
if not re.match('^[0-9a-fA-F]{32}$', ki):
|
||||
raise ValueError('Ki needs to be 128 bits, in hex format')
|
||||
else:
|
||||
ki = ''.join(['%02x' % random.randrange(0, 256) for i in range(16)])
|
||||
ki = os.urandom(16).hex()
|
||||
|
||||
# OPC (random)
|
||||
if opts.opc is not None:
|
||||
@@ -441,7 +446,7 @@ def gen_parameters(opts):
|
||||
elif opts.op is not None:
|
||||
opc = derive_milenage_opc(ki, opts.op)
|
||||
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)
|
||||
|
||||
@@ -586,7 +591,7 @@ def read_params_csv(opts, imsi=None, iccid=None):
|
||||
else:
|
||||
row['mnc'] = row.get('mnc', mnc_from_imsi(row.get('imsi'), False))
|
||||
|
||||
# NOTE: We might concider to specify a new CSV field "mnclen" in our
|
||||
# NOTE: We might consider to specify a new CSV field "mnclen" in our
|
||||
# CSV files for a better automatization. However, this only makes sense
|
||||
# when the tools and databases we export our files from will also add
|
||||
# such a field.
|
||||
@@ -770,6 +775,9 @@ if __name__ == '__main__':
|
||||
# Parse options
|
||||
opts = parse_options()
|
||||
|
||||
# Setup logger
|
||||
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
|
||||
|
||||
# Init card reader driver
|
||||
sl = init_reader(opts)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
#
|
||||
# Utility to display some informations about a SIM card
|
||||
# Utility to display some information about a SIM card
|
||||
#
|
||||
#
|
||||
# Copyright (C) 2009 Sylvain Munaut <tnt@246tNt.com>
|
||||
@@ -25,7 +25,6 @@
|
||||
import hashlib
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
@@ -44,12 +43,19 @@ from pySim.exceptions import SwMatchError
|
||||
from pySim.legacy.cards import card_detect, SimCard, UsimCard, IsimCard
|
||||
from pySim.utils import dec_imsi, dec_iccid
|
||||
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',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
|
||||
argparse_add_reader_args(option_parser)
|
||||
|
||||
|
||||
def select_app(adf: str, card: SimCard):
|
||||
"""Select application by its AID"""
|
||||
sw = 0
|
||||
@@ -74,6 +80,9 @@ if __name__ == '__main__':
|
||||
# Parse options
|
||||
opts = option_parser.parse_args()
|
||||
|
||||
# Setup logger
|
||||
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
|
||||
|
||||
# Init card reader driver
|
||||
sl = init_reader(opts)
|
||||
|
||||
@@ -141,6 +150,15 @@ if __name__ == '__main__':
|
||||
(res, sw) = card.read_record('SMSP', 1)
|
||||
if sw == '9000':
|
||||
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:
|
||||
print("SMSP: Can't read, response code = %s" % (sw,))
|
||||
|
||||
|
||||
180
pySim-shell.py
180
pySim-shell.py
@@ -22,19 +22,25 @@ from typing import List, Optional
|
||||
import json
|
||||
import traceback
|
||||
import re
|
||||
|
||||
import cmd2
|
||||
from packaging import version
|
||||
from cmd2 import style
|
||||
|
||||
import logging
|
||||
from pySim.log import PySimLogger
|
||||
from osmocom.utils import auto_uint8
|
||||
|
||||
# cmd2 >= 2.3.0 has deprecated the bg/fg in favor of Bg/Fg :(
|
||||
if version.parse(cmd2.__version__) < version.parse("2.3.0"):
|
||||
from cmd2 import fg, bg # pylint: disable=no-name-in-module
|
||||
RED = fg.red
|
||||
YELLOW = fg.yellow
|
||||
LIGHT_RED = fg.bright_red
|
||||
LIGHT_GREEN = fg.bright_green
|
||||
else:
|
||||
from cmd2 import Fg, Bg # pylint: disable=no-name-in-module
|
||||
RED = Fg.RED
|
||||
YELLOW = Fg.YELLOW
|
||||
LIGHT_RED = Fg.LIGHT_RED
|
||||
LIGHT_GREEN = Fg.LIGHT_GREEN
|
||||
from cmd2 import CommandSet, with_default_category, with_argparser
|
||||
@@ -63,10 +69,12 @@ from pySim.ts_102_222 import Ts102222Commands
|
||||
from pySim.gsm_r import DF_EIRENE
|
||||
from pySim.cat import ProactiveCommand
|
||||
|
||||
from pySim.card_key_provider import CardKeyProviderCsv, card_key_provider_register, card_key_provider_get_field
|
||||
from pySim.card_key_provider import argparse_add_card_key_provider_args, init_card_key_provider
|
||||
from pySim.card_key_provider import card_key_provider_get_field, card_key_provider_get
|
||||
|
||||
from pySim.app import init_card
|
||||
|
||||
log = PySimLogger.get(Path(__file__).stem)
|
||||
|
||||
class Cmd2Compat(cmd2.Cmd):
|
||||
"""Backwards-compatibility wrapper around cmd2.Cmd to support older and newer
|
||||
@@ -92,12 +100,16 @@ class PysimApp(Cmd2Compat):
|
||||
(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 """
|
||||
|
||||
def __init__(self, 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
|
||||
PySimLogger.setup(self.poutput, {logging.WARN: YELLOW})
|
||||
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)
|
||||
@@ -124,8 +136,10 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
||||
self.add_settable(Settable2Compat('apdu_trace', bool, 'Trace and display APDUs exchanged with card', self,
|
||||
onchange_cb=self._onchange_apdu_trace))
|
||||
self.add_settable(Settable2Compat('apdu_strict', bool,
|
||||
'Enforce APDU responses according to ISO/IEC 7816-3, table 12', self,
|
||||
onchange_cb=self._onchange_apdu_strict))
|
||||
'Strictly apply APDU format according to ISO/IEC 7816-3, table 12', self))
|
||||
self.add_settable(Settable2Compat('verbose', bool,
|
||||
'Enable/disable verbose logging', self,
|
||||
onchange_cb=self._onchange_verbose))
|
||||
self.equip(card, rs)
|
||||
|
||||
def equip(self, card, rs):
|
||||
@@ -203,12 +217,12 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
||||
else:
|
||||
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):
|
||||
PySimLogger.set_verbose(new)
|
||||
if new == True:
|
||||
PySimLogger.set_level(logging.DEBUG)
|
||||
else:
|
||||
PySimLogger.set_level(logging.INFO)
|
||||
|
||||
class Cmd2ApduTracer(ApduTracer):
|
||||
def __init__(self, cmd2_app):
|
||||
@@ -259,23 +273,32 @@ 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-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('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)
|
||||
def do_apdu(self, opts):
|
||||
"""Send a raw APDU to the card, and print SW + Response.
|
||||
CAUTION: this command bypasses the logical channel handling of pySim-shell and card state changes are not
|
||||
tracked. Dpending 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."""
|
||||
|
||||
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
|
||||
# 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
|
||||
# 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:
|
||||
data, sw = self.card._scc.send_apdu(opts.APDU, apply_lchan = False)
|
||||
else:
|
||||
data, sw = self.lchan.scc.send_apdu(opts.APDU, apply_lchan = False)
|
||||
self.card._scc._tp.apdu_strict = True
|
||||
if data:
|
||||
self.poutput("SW: %s, RESP: %s" % (sw, data))
|
||||
else:
|
||||
@@ -336,7 +359,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
||||
|
||||
def _process_card(self, first, script_path):
|
||||
|
||||
# Early phase of card initialzation (this part may fail with an exception)
|
||||
# Early phase of card initialization (this part may fail with an exception)
|
||||
try:
|
||||
rs, card = init_card(self.sl)
|
||||
rc = self.equip(card, rs)
|
||||
@@ -377,7 +400,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
||||
|
||||
bulk_script_parser = argparse.ArgumentParser()
|
||||
bulk_script_parser.add_argument('SCRIPT_PATH', help="path to the script file")
|
||||
bulk_script_parser.add_argument('--halt_on_error', help='stop card handling if an exeption occurs',
|
||||
bulk_script_parser.add_argument('--halt_on_error', help='stop card handling if an exception occurs',
|
||||
action='store_true')
|
||||
bulk_script_parser.add_argument('--tries', type=int, default=2,
|
||||
help='how many tries before trying the next card')
|
||||
@@ -477,11 +500,37 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
|
||||
"""Echo (print) a string on the console"""
|
||||
self.poutput(' '.join(opts.STRING))
|
||||
|
||||
query_card_key_parser = argparse.ArgumentParser()
|
||||
query_card_key_parser.add_argument('FIELDS', help="fields to query", type=str, nargs='+')
|
||||
query_card_key_parser.add_argument('--key', help='lookup key (typically \'ICCID\' or \'EID\')',
|
||||
type=str, required=True)
|
||||
query_card_key_parser.add_argument('--value', help='lookup key match value (e.g \'8988211000000123456\')',
|
||||
type=str, required=True)
|
||||
@cmd2.with_argparser(query_card_key_parser)
|
||||
@cmd2.with_category(CUSTOM_CATEGORY)
|
||||
def do_query_card_key(self, opts):
|
||||
"""Manually query the Card Key Provider"""
|
||||
result = card_key_provider_get(opts.FIELDS, opts.key, opts.value)
|
||||
self.poutput("Result:")
|
||||
if result == {}:
|
||||
self.poutput(" (none)")
|
||||
for k in result.keys():
|
||||
self.poutput(" %s: %s" % (str(k), str(result.get(k))))
|
||||
|
||||
@cmd2.with_category(CUSTOM_CATEGORY)
|
||||
def do_version(self, opts):
|
||||
"""Print the pySim software version."""
|
||||
import pkg_resources
|
||||
self.poutput(pkg_resources.get_distribution('pySim'))
|
||||
from importlib.metadata import version as vsn
|
||||
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')
|
||||
class PySimCommands(CommandSet):
|
||||
@@ -731,7 +780,7 @@ class PySimCommands(CommandSet):
|
||||
body = {}
|
||||
for t in tags:
|
||||
result = self._cmd.lchan.retrieve_data(t)
|
||||
(tag, l, val, remainer) = bertlv_parse_one(h2b(result[0]))
|
||||
(tag, l, val, remainder) = bertlv_parse_one(h2b(result[0]))
|
||||
body[t] = b2h(val)
|
||||
else:
|
||||
raise RuntimeError('Unsupported structure "%s" of file "%s"' % (structure, filename))
|
||||
@@ -915,36 +964,53 @@ class Iso7816Commands(CommandSet):
|
||||
raise RuntimeError("cannot find %s for ICCID '%s'" % (field, iccid))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def __select_pin_nr(pin_type:str, pin_nr:int) -> int:
|
||||
if pin_type:
|
||||
# pylint: disable=unsubscriptable-object
|
||||
return pin_names.inverse[pin_type]
|
||||
return pin_nr
|
||||
|
||||
@staticmethod
|
||||
def __add_pin_nr_to_ArgumentParser(chv_parser):
|
||||
group = chv_parser.add_mutually_exclusive_group()
|
||||
group.add_argument('--pin-type',
|
||||
choices=[x for x in pin_names.values()
|
||||
if (x.startswith('PIN') or x.startswith('2PIN'))],
|
||||
help='Specifiy pin type (default is PIN1)')
|
||||
group.add_argument('--pin-nr', type=auto_uint8, default=0x01,
|
||||
help='PIN Number, 1=PIN1, 0x81=2PIN1 or custom value (see also TS 102 221, Table 9.3")')
|
||||
|
||||
verify_chv_parser = argparse.ArgumentParser()
|
||||
verify_chv_parser.add_argument(
|
||||
'--pin-nr', type=int, default=1, help='PIN Number, 1=PIN1, 2=PIN2 or custom value (decimal)')
|
||||
verify_chv_parser.add_argument('PIN', nargs='?', type=is_decimal,
|
||||
help='PIN code value. If none given, CSV file will be queried')
|
||||
__add_pin_nr_to_ArgumentParser(verify_chv_parser)
|
||||
|
||||
@cmd2.with_argparser(verify_chv_parser)
|
||||
def do_verify_chv(self, opts):
|
||||
"""Verify (authenticate) using specified CHV (PIN) code, which is how the specifications
|
||||
call it if you authenticate yourself using the specified PIN. There usually is at least PIN1 and
|
||||
PIN2."""
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(opts.pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.verify_chv(opts.pin_nr, h2b(pin))
|
||||
2PIN1 (see also TS 102 221 Section 9.5.1 / Table 9.3)."""
|
||||
pin_nr = self.__select_pin_nr(opts.pin_type, opts.pin_nr)
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.verify_chv(pin_nr, h2b(pin))
|
||||
self._cmd.poutput("CHV verification successful")
|
||||
|
||||
unblock_chv_parser = argparse.ArgumentParser()
|
||||
unblock_chv_parser.add_argument(
|
||||
'--pin-nr', type=int, default=1, help='PUK Number, 1=PIN1, 2=PIN2 or custom value (decimal)')
|
||||
unblock_chv_parser.add_argument('PUK', nargs='?', type=is_decimal,
|
||||
help='PUK code value. If none given, CSV file will be queried')
|
||||
unblock_chv_parser.add_argument('NEWPIN', nargs='?', type=is_decimal,
|
||||
help='PIN code value. If none given, CSV file will be queried')
|
||||
__add_pin_nr_to_ArgumentParser(unblock_chv_parser)
|
||||
|
||||
@cmd2.with_argparser(unblock_chv_parser)
|
||||
def do_unblock_chv(self, opts):
|
||||
"""Unblock PIN code using specified PUK code"""
|
||||
new_pin = self.get_code(opts.NEWPIN, "PIN" + str(opts.pin_nr))
|
||||
puk = self.get_code(opts.PUK, "PUK" + str(opts.pin_nr))
|
||||
pin_nr = self.__select_pin_nr(opts.pin_type, opts.pin_nr)
|
||||
new_pin = self.get_code(opts.NEWPIN, "PIN" + str(pin_nr))
|
||||
puk = self.get_code(opts.PUK, "PUK" + str(pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.unblock_chv(
|
||||
opts.pin_nr, h2b(puk), h2b(new_pin))
|
||||
pin_nr, h2b(puk), h2b(new_pin))
|
||||
self._cmd.poutput("CHV unblock successful")
|
||||
|
||||
change_chv_parser = argparse.ArgumentParser()
|
||||
@@ -952,42 +1018,42 @@ class Iso7816Commands(CommandSet):
|
||||
help='PIN code value. If none given, CSV file will be queried')
|
||||
change_chv_parser.add_argument('PIN', nargs='?', type=is_decimal,
|
||||
help='PIN code value. If none given, CSV file will be queried')
|
||||
change_chv_parser.add_argument(
|
||||
'--pin-nr', type=int, default=1, help='PUK Number, 1=PIN1, 2=PIN2 or custom value (decimal)')
|
||||
__add_pin_nr_to_ArgumentParser(change_chv_parser)
|
||||
|
||||
@cmd2.with_argparser(change_chv_parser)
|
||||
def do_change_chv(self, opts):
|
||||
"""Change PIN code to a new PIN code"""
|
||||
new_pin = self.get_code(opts.NEWPIN, "PIN" + str(opts.pin_nr))
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(opts.pin_nr))
|
||||
pin_nr = self.__select_pin_nr(opts.pin_type, opts.pin_nr)
|
||||
new_pin = self.get_code(opts.NEWPIN, "PIN" + str(pin_nr))
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.change_chv(
|
||||
opts.pin_nr, h2b(pin), h2b(new_pin))
|
||||
pin_nr, h2b(pin), h2b(new_pin))
|
||||
self._cmd.poutput("CHV change successful")
|
||||
|
||||
disable_chv_parser = argparse.ArgumentParser()
|
||||
disable_chv_parser.add_argument(
|
||||
'--pin-nr', type=int, default=1, help='PIN Number, 1=PIN1, 2=PIN2 or custom value (decimal)')
|
||||
disable_chv_parser.add_argument('PIN', nargs='?', type=is_decimal,
|
||||
help='PIN code value. If none given, CSV file will be queried')
|
||||
__add_pin_nr_to_ArgumentParser(disable_chv_parser)
|
||||
|
||||
@cmd2.with_argparser(disable_chv_parser)
|
||||
def do_disable_chv(self, opts):
|
||||
"""Disable PIN code using specified PIN code"""
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(opts.pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.disable_chv(opts.pin_nr, h2b(pin))
|
||||
pin_nr = self.__select_pin_nr(opts.pin_type, opts.pin_nr)
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.disable_chv(pin_nr, h2b(pin))
|
||||
self._cmd.poutput("CHV disable successful")
|
||||
|
||||
enable_chv_parser = argparse.ArgumentParser()
|
||||
enable_chv_parser.add_argument(
|
||||
'--pin-nr', type=int, default=1, help='PIN Number, 1=PIN1, 2=PIN2 or custom value (decimal)')
|
||||
__add_pin_nr_to_ArgumentParser(enable_chv_parser)
|
||||
enable_chv_parser.add_argument('PIN', nargs='?', type=is_decimal,
|
||||
help='PIN code value. If none given, CSV file will be queried')
|
||||
|
||||
@cmd2.with_argparser(enable_chv_parser)
|
||||
def do_enable_chv(self, opts):
|
||||
"""Enable PIN code using specified PIN code"""
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(opts.pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.enable_chv(opts.pin_nr, h2b(pin))
|
||||
pin_nr = self.__select_pin_nr(opts.pin_type, opts.pin_nr)
|
||||
pin = self.get_code(opts.PIN, "PIN" + str(pin_nr))
|
||||
(data, sw) = self._cmd.lchan.scc.enable_chv(pin_nr, h2b(pin))
|
||||
self._cmd.poutput("CHV enable successful")
|
||||
|
||||
def do_deactivate_file(self, opts):
|
||||
@@ -1055,15 +1121,12 @@ class Iso7816Commands(CommandSet):
|
||||
fcp_dec = self._cmd.lchan.status()
|
||||
self._cmd.poutput_json(fcp_dec)
|
||||
|
||||
|
||||
class Proact(ProactiveHandler):
|
||||
def receive_fetch(self, pcmd: ProactiveCommand):
|
||||
# print its parsed representation
|
||||
print(pcmd.decoded)
|
||||
# TODO: implement the basics, such as SMS Sending, ...
|
||||
|
||||
|
||||
|
||||
option_parser = argparse.ArgumentParser(description='interactive SIM card shell',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
argparse_add_reader_args(option_parser)
|
||||
@@ -1071,16 +1134,14 @@ argparse_add_reader_args(option_parser)
|
||||
global_group = option_parser.add_argument_group('General Options')
|
||||
global_group.add_argument('--script', metavar='PATH', default=None,
|
||||
help='script with pySim-shell commands to be executed automatically at start-up')
|
||||
global_group.add_argument('--csv', metavar='FILE',
|
||||
default=None, help='Read card data from CSV file')
|
||||
global_group.add_argument('--csv-column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
|
||||
help='per-CSV-column AES transport key')
|
||||
global_group.add_argument("--card_handler", dest="card_handler_config", metavar="FILE",
|
||||
help="Use automatic card handling machine")
|
||||
global_group.add_argument("--noprompt", help="Run in non interactive mode",
|
||||
action='store_true', default=False)
|
||||
global_group.add_argument("--skip-card-init", help="Skip all card/profile initialization",
|
||||
action='store_true', default=False)
|
||||
global_group.add_argument("--verbose", help="Enable verbose logging",
|
||||
action='store_true', default=False)
|
||||
|
||||
adm_group = global_group.add_mutually_exclusive_group()
|
||||
adm_group.add_argument('-a', '--pin-adm', metavar='PIN_ADM1', dest='pin_adm', default=None,
|
||||
@@ -1094,24 +1155,17 @@ option_parser.add_argument("command", nargs='?',
|
||||
help="A pySim-shell command that would optionally be executed at startup")
|
||||
option_parser.add_argument('command_args', nargs=argparse.REMAINDER,
|
||||
help="Optional Arguments for command")
|
||||
|
||||
argparse_add_card_key_provider_args(option_parser)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
startup_errors = False
|
||||
opts = option_parser.parse_args()
|
||||
|
||||
# Register csv-file as card data provider, either from specified CSV
|
||||
# or from CSV file in home directory
|
||||
csv_column_keys = {}
|
||||
for par in opts.csv_column_key:
|
||||
name, key = par.split(':')
|
||||
csv_column_keys[name] = key
|
||||
csv_default = str(Path.home()) + "/.osmocom/pysim/card_data.csv"
|
||||
if opts.csv:
|
||||
card_key_provider_register(CardKeyProviderCsv(opts.csv, csv_column_keys))
|
||||
if os.path.isfile(csv_default):
|
||||
card_key_provider_register(CardKeyProviderCsv(csv_default, csv_column_keys))
|
||||
# Ensure that we are able to print formatted warnings from the beginning.
|
||||
PySimLogger.setup(print, {logging.WARN: YELLOW}, opts.verbose)
|
||||
|
||||
# Init card key provider for automatic card key retrieval
|
||||
init_card_key_provider(opts)
|
||||
|
||||
# Init card reader driver
|
||||
sl = init_reader(opts, proactive_handler = Proact())
|
||||
@@ -1127,7 +1181,7 @@ if __name__ == '__main__':
|
||||
# able to tolerate and recover from that.
|
||||
try:
|
||||
rs, card = init_card(sl, opts.skip_card_init)
|
||||
app = PysimApp(card, rs, sl, ch)
|
||||
app = PysimApp(opts.verbose, card, rs, sl, ch)
|
||||
except:
|
||||
startup_errors = True
|
||||
print("Card initialization (%s) failed with an exception:" % str(sl))
|
||||
@@ -1139,7 +1193,7 @@ if __name__ == '__main__':
|
||||
print(" it should also be noted that some readers may behave strangely when no card")
|
||||
print(" is inserted.)")
|
||||
print("")
|
||||
app = PysimApp(None, None, sl, ch)
|
||||
app = PysimApp(opts.verbose, None, None, sl, ch)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -84,7 +84,7 @@ def tcp_connected_callback(p: protocol.Protocol):
|
||||
logger.error("%s: connected!" % p)
|
||||
|
||||
class ProactChannel:
|
||||
"""Representation of a single proective channel."""
|
||||
"""Representation of a single protective channel."""
|
||||
def __init__(self, channels: 'ProactChannels', chan_nr: int):
|
||||
self.channels = channels
|
||||
self.chan_nr = chan_nr
|
||||
|
||||
@@ -33,11 +33,10 @@ logger = colorlog.getLogger()
|
||||
|
||||
# merge all of the command sets into one global set. This will override instructions,
|
||||
# the one from the 'last' set in the addition below will prevail.
|
||||
from pySim.apdu.ts_51_011 import ApduCommands as SimApduCommands
|
||||
from pySim.apdu.ts_102_221 import ApduCommands as UiccApduCommands
|
||||
from pySim.apdu.ts_31_102 import ApduCommands as UsimApduCommands
|
||||
from pySim.apdu.global_platform import ApduCommands as GpApduCommands
|
||||
ApduCommands = SimApduCommands + UiccApduCommands + UsimApduCommands #+ GpApduCommands
|
||||
ApduCommands = UiccApduCommands + UsimApduCommands #+ GpApduCommands
|
||||
|
||||
|
||||
class DummySimLink(LinkBase):
|
||||
@@ -153,7 +152,7 @@ global_group.add_argument('--no-suppress-select', action='store_false', dest='su
|
||||
global_group.add_argument('--no-suppress-status', action='store_false', dest='suppress_status',
|
||||
help="""
|
||||
Don't suppress displaying STATUS APDUs. We normally suppress them as they don't provide any
|
||||
information that was not already received in resposne to the most recent SEELCT.""")
|
||||
information that was not already received in response to the most recent SEELCT.""")
|
||||
global_group.add_argument('--show-raw-apdu', action='store_true', dest='show_raw_apdu',
|
||||
help="""Show the raw APDU in addition to its parsed form.""")
|
||||
|
||||
@@ -190,7 +189,7 @@ parser_rspro_pyshark_live.add_argument('-i', '--interface', required=True,
|
||||
parser_tcaloader_log = subparsers.add_parser('tca-loader-log', help="""
|
||||
Read APDUs from a TCA Loader log file.""")
|
||||
parser_tcaloader_log.add_argument('-f', '--log-file', required=True,
|
||||
help='Name of te 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.""")
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
# coding=utf-8
|
||||
"""APDU definitions/decoders of 3GPP TS 51.011, the classic SIM spec.
|
||||
|
||||
(C) 2022 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/>.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from construct import GreedyRange, Struct
|
||||
from pySim.construct import *
|
||||
from pySim.filesystem import *
|
||||
from pySim.runtime import RuntimeLchan
|
||||
from pySim.apdu import ApduCommand, ApduCommandSet
|
||||
from typing import Optional, Dict, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TS 51.011 Section 9.2.1
|
||||
class SimSelect(ApduCommand, n='SELECT', ins=0xA4, cla=['A0']):
|
||||
_apdu_case = 4
|
||||
|
||||
def process_on_lchan(self, lchan: RuntimeLchan):
|
||||
path = [self.cmd_data[i:i+2] for i in range(0, len(self.cmd_data), 2)]
|
||||
for file in path:
|
||||
file_hex = b2h(file)
|
||||
sels = lchan.selected_file.get_selectables(['FIDS'])
|
||||
if file_hex in sels:
|
||||
if self.successful:
|
||||
#print("\tSELECT %s" % sels[file_hex])
|
||||
lchan.selected_file = sels[file_hex]
|
||||
else:
|
||||
#print("\tSELECT %s FAILED" % sels[file_hex])
|
||||
pass
|
||||
continue
|
||||
logger.warning('SELECT UNKNOWN FID %s (%s)' % (file_hex, '/'.join([b2h(x) for x in path])))
|
||||
if len(self.cmd_data) != 2:
|
||||
raise ValueError('Expecting a 2-byte FID')
|
||||
|
||||
# decode the SELECT response
|
||||
if self.successful:
|
||||
self.file = lchan.selected_file
|
||||
if 'body' in self.rsp_dict:
|
||||
# not every SELECT is asking for the FCP in response...
|
||||
return lchan.selected_file.decode_select_response(self.rsp_dict['body'])
|
||||
return None
|
||||
|
||||
|
||||
# TS 51.011 Section 9.2.2
|
||||
class SimStatus(ApduCommand, n='STATUS', ins=0xF2, cla=['A0']):
|
||||
_apdu_case = 2
|
||||
|
||||
def process_on_lchan(self, lchan):
|
||||
if self.successful:
|
||||
if 'body' in self.rsp_dict:
|
||||
return lchan.selected_file.decode_select_response(self.rsp_dict['body'])
|
||||
|
||||
def _decode_binary_p1p2(p1, p2) -> Dict:
|
||||
ret = {}
|
||||
if p1 & 0x80:
|
||||
ret['file'] = 'sfi'
|
||||
ret['sfi'] = p1 & 0x1f
|
||||
ret['offset'] = p2
|
||||
else:
|
||||
ret['file'] = 'currently_selected_ef'
|
||||
ret['offset'] = ((p1 & 0x7f) << 8) & p2
|
||||
return ret
|
||||
|
||||
# TS 51.011 Section 9.2.3 / 31.101
|
||||
class ReadBinary(ApduCommand, n='READ BINARY', ins=0xB0, cla=['A0']):
|
||||
_apdu_case = 2
|
||||
def _decode_p1p2(self):
|
||||
return _decode_binary_p1p2(self.p1, self.p2)
|
||||
|
||||
def process_on_lchan(self, lchan):
|
||||
self._determine_file(lchan)
|
||||
if not isinstance(self.file, TransparentEF):
|
||||
return b2h(self.rsp_data)
|
||||
# our decoders don't work for non-zero offsets / short reads
|
||||
if self.cmd_dict['offset'] != 0 or self.lr < self.file.size[0]:
|
||||
return b2h(self.rsp_data)
|
||||
method = getattr(self.file, 'decode_bin', None)
|
||||
if self.successful and callable(method):
|
||||
return method(self.rsp_data)
|
||||
|
||||
# TS 51.011 Section 9.2.4 / 31.101
|
||||
class UpdateBinary(ApduCommand, n='UPDATE BINARY', ins=0xD6, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
def _decode_p1p2(self):
|
||||
return _decode_binary_p1p2(self.p1, self.p2)
|
||||
|
||||
def process_on_lchan(self, lchan):
|
||||
self._determine_file(lchan)
|
||||
if not isinstance(self.file, TransparentEF):
|
||||
return b2h(self.rsp_data)
|
||||
# our decoders don't work for non-zero offsets / short writes
|
||||
if self.cmd_dict['offset'] != 0 or self.lc < self.file.size[0]:
|
||||
return b2h(self.cmd_data)
|
||||
method = getattr(self.file, 'decode_bin', None)
|
||||
if self.successful and callable(method):
|
||||
return method(self.cmd_data)
|
||||
|
||||
def _decode_record_p1p2(p1, p2):
|
||||
ret = {}
|
||||
ret['record_number'] = p1
|
||||
if p2 >> 3 == 0:
|
||||
ret['file'] = 'currently_selected_ef'
|
||||
else:
|
||||
ret['file'] = 'sfi'
|
||||
ret['sfi'] = p2 >> 3
|
||||
mode = p2 & 0x7
|
||||
if mode == 2:
|
||||
ret['mode'] = 'next_record'
|
||||
elif mode == 3:
|
||||
ret['mode'] = 'previous_record'
|
||||
elif mode == 8:
|
||||
ret['mode'] = 'absolute_current'
|
||||
return ret
|
||||
|
||||
# TS 51.011 Section 9.2.5
|
||||
class ReadRecord(ApduCommand, n='READ RECORD', ins=0xB2, cla=['A0']):
|
||||
_apdu_case = 2
|
||||
def _decode_p1p2(self):
|
||||
r = _decode_record_p1p2(self.p1, self.p2)
|
||||
self.col_id = '%02u' % r['record_number']
|
||||
return r
|
||||
|
||||
def process_on_lchan(self, lchan):
|
||||
self._determine_file(lchan)
|
||||
if not isinstance(self.file, LinFixedEF):
|
||||
return b2h(self.rsp_data)
|
||||
method = getattr(self.file, 'decode_record_bin', None)
|
||||
if self.successful and callable(method):
|
||||
return method(self.rsp_data)
|
||||
|
||||
# TS 51.011 Section 9.2.6
|
||||
class UpdateRecord(ApduCommand, n='UPDATE RECORD', ins=0xDC, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
def _decode_p1p2(self):
|
||||
r = _decode_record_p1p2(self.p1, self.p2)
|
||||
self.col_id = '%02u' % r['record_number']
|
||||
return r
|
||||
|
||||
def process_on_lchan(self, lchan):
|
||||
self._determine_file(lchan)
|
||||
if not isinstance(self.file, LinFixedEF):
|
||||
return b2h(self.cmd_data)
|
||||
method = getattr(self.file, 'decode_record_bin', None)
|
||||
if self.successful and callable(method):
|
||||
return method(self.cmd_data)
|
||||
|
||||
# TS 51.011 Section 9.2.7
|
||||
class Seek(ApduCommand, n='SEEK', ins=0xA2, cla=['A0']):
|
||||
_apdu_case = 4
|
||||
_construct_rsp = GreedyRange(Int8ub)
|
||||
|
||||
def _decode_p1p2(self):
|
||||
ret = {}
|
||||
sfi = self.p2 >> 3
|
||||
if sfi == 0:
|
||||
ret['file'] = 'currently_selected_ef'
|
||||
else:
|
||||
ret['file'] = 'sfi'
|
||||
ret['sfi'] = sfi
|
||||
mode = self.p2 & 0x7
|
||||
if mode in [0x4, 0x5]:
|
||||
if mode == 0x4:
|
||||
ret['mode'] = 'forward_search'
|
||||
else:
|
||||
ret['mode'] = 'backward_search'
|
||||
ret['record_number'] = self.p1
|
||||
self.col_id = '%02u' % ret['record_number']
|
||||
elif mode == 6:
|
||||
ret['mode'] = 'enhanced_search'
|
||||
# TODO: further decode
|
||||
elif mode == 7:
|
||||
ret['mode'] = 'proprietary_search'
|
||||
return ret
|
||||
|
||||
def _decode_cmd(self):
|
||||
ret = self._decode_p1p2()
|
||||
if self.cmd_data:
|
||||
if ret['mode'] == 'enhanced_search':
|
||||
ret['search_indication'] = b2h(self.cmd_data[:2])
|
||||
ret['search_string'] = b2h(self.cmd_data[2:])
|
||||
else:
|
||||
ret['search_string'] = b2h(self.cmd_data)
|
||||
return ret
|
||||
|
||||
def process_on_lchan(self, lchan):
|
||||
self._determine_file(lchan)
|
||||
return self.to_dict()
|
||||
|
||||
# TS 51.011 Section 9.2.8
|
||||
class Increase(ApduCommand, n='INCREASE', ins=0x32, cla=['A0']):
|
||||
_apdu_case = 4
|
||||
|
||||
PinConstructP2 = BitStruct('scope'/Enum(Flag, global_mf=0, specific_df_adf=1),
|
||||
BitsInteger(2), 'reference_data_nr'/BitsInteger(5))
|
||||
|
||||
# TS 51.011 Section 9.2.9
|
||||
class VerifyChv(ApduCommand, n='VERIFY CHV', ins=0x20, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
_construct_p2 = PinConstructP2
|
||||
|
||||
@staticmethod
|
||||
def _pin_process(apdu):
|
||||
processed = {
|
||||
'scope': apdu.cmd_dict['p2']['scope'],
|
||||
'referenced_data_nr': apdu.cmd_dict['p2']['reference_data_nr'],
|
||||
}
|
||||
if apdu.lc == 0:
|
||||
# this is just a question on the counters remaining
|
||||
processed['mode'] = 'check_remaining_attempts'
|
||||
else:
|
||||
processed['pin'] = b2h(apdu.cmd_data)
|
||||
if apdu.sw[0] == 0x63:
|
||||
processed['remaining_attempts'] = apdu.sw[1] & 0xf
|
||||
return processed
|
||||
|
||||
@staticmethod
|
||||
def _pin_is_success(sw):
|
||||
if sw[0] == 0x63:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def process_on_lchan(self, lchan: RuntimeLchan):
|
||||
return VerifyChv._pin_process(self)
|
||||
|
||||
def _is_success(self):
|
||||
return VerifyChv._pin_is_success(self.sw)
|
||||
|
||||
|
||||
# TS 51.011 Section 9.2.10
|
||||
class ChangeChv(ApduCommand, n='CHANGE CHV', ins=0x24, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
_construct_p2 = PinConstructP2
|
||||
|
||||
def process_on_lchan(self, lchan: RuntimeLchan):
|
||||
return VerifyChv._pin_process(self)
|
||||
|
||||
def _is_success(self):
|
||||
return VerifyChv._pin_is_success(self.sw)
|
||||
|
||||
|
||||
# TS 51.011 Section 9.2.11
|
||||
class DisableChv(ApduCommand, n='DISABLE CHV', ins=0x26, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
_construct_p2 = PinConstructP2
|
||||
|
||||
def process_on_lchan(self, lchan: RuntimeLchan):
|
||||
return VerifyChv._pin_process(self)
|
||||
|
||||
def _is_success(self):
|
||||
return VerifyChv._pin_is_success(self.sw)
|
||||
|
||||
|
||||
# TS 51.011 Section 9.2.12
|
||||
class EnableChv(ApduCommand, n='ENABLE CHV', ins=0x28, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
_construct_p2 = PinConstructP2
|
||||
def process_on_lchan(self, lchan: RuntimeLchan):
|
||||
return VerifyChv._pin_process(self)
|
||||
|
||||
def _is_success(self):
|
||||
return VerifyChv._pin_is_success(self.sw)
|
||||
|
||||
|
||||
# TS 51.011 Section 9.2.13
|
||||
class UnblockChv(ApduCommand, n='UNBLOCK CHV', ins=0x2C, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
_construct_p2 = PinConstructP2
|
||||
|
||||
def process_on_lchan(self, lchan: RuntimeLchan):
|
||||
return VerifyChv._pin_process(self)
|
||||
|
||||
def _is_success(self):
|
||||
return VerifyChv._pin_is_success(self.sw)
|
||||
|
||||
|
||||
# TS 51.011 Section 9.2.14
|
||||
class Invalidate(ApduCommand, n='INVALIDATE', ins=0x04, cla=['A0']):
|
||||
_apdu_case = 1
|
||||
_construct_p1 = BitStruct(BitsInteger(4),
|
||||
'select_mode'/Enum(BitsInteger(4), ef_by_file_id=0,
|
||||
path_from_mf=8, path_from_current_df=9))
|
||||
|
||||
# TS 51.011 Section 9.2.15
|
||||
class Rehabilitate(ApduCommand, n='REHABILITATE', ins=0x44, cla=['A0']):
|
||||
_apdu_case = 1
|
||||
_construct_p1 = Invalidate._construct_p1
|
||||
|
||||
# TS 51.011 Section 9.2.16
|
||||
class RunGsmAlgorithm(ApduCommand, n='RUN GSM ALGORITHM', ins=0x88, cla=['A0']):
|
||||
_apdu_case = 4
|
||||
_construct = Struct('rand'/Bytes(16))
|
||||
_construct_rsp = Struct('sres'/Bytes(4), 'kc'/Bytes(8))
|
||||
|
||||
# TS 51.011 Section 9.2.17
|
||||
class Sleep(ApduCommand, n='SLEEP', ins=0xFA, cla=['A0']):
|
||||
_apdu_case = 2
|
||||
|
||||
# TS 51.011 Section 9.2.18
|
||||
class GetResponse(ApduCommand, n='GET RESPONSE', ins=0xC0, cla=['A0']):
|
||||
_apdu_case = 2
|
||||
|
||||
# TS 51.011 Section 9.2.19
|
||||
class TerminalProfile(ApduCommand, n='TERMINAL PROFILE', ins=0x10, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
|
||||
# TS 51.011 Section 9.2.20
|
||||
class Envelope(ApduCommand, n='ENVELOPE', ins=0xC2, cla=['A0']):
|
||||
_apdu_case = 4
|
||||
|
||||
# TS 51.011 Section 9.2.21
|
||||
class Fetch(ApduCommand, n='FETCH', ins=0x12, cla=['A0']):
|
||||
_apdu_case = 2
|
||||
|
||||
# TS 51.011 Section 9.2.22
|
||||
class TerminalResponse(ApduCommand, n='TERMINAL RESPONSE', ins=0x14, cla=['A0']):
|
||||
_apdu_case = 3
|
||||
|
||||
|
||||
ApduCommands = ApduCommandSet('TS 51.011', cmds=[SimSelect, SimStatus, ReadBinary, UpdateBinary, ReadRecord,
|
||||
UpdateRecord, Seek, Increase, VerifyChv, ChangeChv, DisableChv,
|
||||
EnableChv, UnblockChv, Invalidate, Rehabilitate, RunGsmAlgorithm,
|
||||
Sleep, GetResponse, TerminalProfile, Envelope, Fetch, TerminalResponse])
|
||||
@@ -84,5 +84,5 @@ class PysharkGsmtapPcap(_PysharkGsmtap):
|
||||
Args:
|
||||
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)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
|
||||
from pySim.utils import h2b
|
||||
from pySim.gsmtap import GsmtapSource
|
||||
|
||||
from pySim.apdu.ts_102_221 import ApduCommands as UiccApduCommands
|
||||
from pySim.apdu.ts_102_222 import ApduCommands as UiccAdmApduCommands
|
||||
@@ -34,6 +33,7 @@ class StdinHexApduSource(ApduSource):
|
||||
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))
|
||||
raise StopIteration
|
||||
|
||||
@@ -31,7 +31,6 @@ from pySim.exceptions import SwMatchError
|
||||
# CardModel is created, which will add the ATR-based matching and
|
||||
# calling of SysmocomSJA2.add_files. See CardModel.apply_matching_models
|
||||
import pySim.sysmocom_sja2
|
||||
#import pySim.sysmocom_euicc1
|
||||
|
||||
# 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
|
||||
|
||||
@@ -72,10 +72,10 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
|
||||
if do[0] == 0x01:
|
||||
self.decoded = {'generic_access_rule': 'always'}
|
||||
return self.decoded
|
||||
return ValueError('Invalid 1-byte generic APDU access rule')
|
||||
raise ValueError('Invalid 1-byte generic APDU access rule')
|
||||
else:
|
||||
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': []}
|
||||
offset = 0
|
||||
while offset < len(do):
|
||||
@@ -90,19 +90,19 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
|
||||
return b'\x00'
|
||||
if self.decoded['generic_access_rule'] == 'always':
|
||||
return b'\x01'
|
||||
return ValueError('Invalid 1-byte generic APDU access rule')
|
||||
raise ValueError('Invalid 1-byte generic APDU access rule')
|
||||
else:
|
||||
if not 'apdu_filter' in self.decoded:
|
||||
return ValueError('Invalid APDU AR DO')
|
||||
raise ValueError('Invalid APDU AR DO')
|
||||
filters = self.decoded['apdu_filter']
|
||||
res = b''
|
||||
for f in filters:
|
||||
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'])
|
||||
mask_b = h2b(f['mask'])
|
||||
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
|
||||
return res
|
||||
|
||||
@@ -269,7 +269,7 @@ class ADF_ARAM(CardADF):
|
||||
cmd_do_enc = cmd_do.to_ie()
|
||||
cmd_do_len = len(cmd_do_enc)
|
||||
if cmd_do_len > 255:
|
||||
return ValueError('DO > 255 bytes not supported yet')
|
||||
raise ValueError('DO > 255 bytes not supported yet')
|
||||
else:
|
||||
cmd_do_enc = b''
|
||||
cmd_do_len = 0
|
||||
@@ -317,7 +317,7 @@ class ADF_ARAM(CardADF):
|
||||
store_ref_ar_do_parse = argparse.ArgumentParser()
|
||||
# REF-DO
|
||||
store_ref_ar_do_parse.add_argument(
|
||||
'--device-app-id', required=True, help='Identifies the specific device application that the rule appplies to. Hash of Certificate of Application Provider, or UUID. (20/32 hex bytes)')
|
||||
'--device-app-id', required=True, help='Identifies the specific device application that the rule applies to. Hash of Certificate of Application Provider, or UUID. (20/32 hex bytes)')
|
||||
aid_grp = store_ref_ar_do_parse.add_mutually_exclusive_group()
|
||||
aid_grp.add_argument(
|
||||
'--aid', help='Identifies the specific SE application for which rules are to be stored. Can be a partial AID, containing for example only the RID. (5-16 or 0 hex bytes)')
|
||||
@@ -361,7 +361,7 @@ class ADF_ARAM(CardADF):
|
||||
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))
|
||||
raise ValueError(f'Invalid non-modulo-16 length of APDU filter: {len(opts.apdu_filter)}')
|
||||
offset = 0
|
||||
apdu_filter = []
|
||||
while offset < len(opts.apdu_filter):
|
||||
@@ -399,7 +399,7 @@ class ADF_ARAM(CardADF):
|
||||
sw_aram = {
|
||||
'ARA-M': {
|
||||
'6381': 'Rule successfully stored but an access rule already exists',
|
||||
'6382': 'Rule successfully stored bu contained at least one unknown (discarded) BER-TLV',
|
||||
'6382': 'Rule successfully stored but contained at least one unknown (discarded) BER-TLV',
|
||||
'6581': 'Memory Problem',
|
||||
'6700': 'Wrong Length in Lc',
|
||||
'6981': 'DO is not supported by the ARA-M/ARA-C',
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
|
||||
@@ -10,7 +10,7 @@ the need of manually entering the related card-individual data on every
|
||||
operation with pySim-shell.
|
||||
"""
|
||||
|
||||
# (C) 2021-2024 by Sysmocom s.f.m.c. GmbH
|
||||
# (C) 2021-2025 by sysmocom - s.f.m.c. GmbH
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Author: Philipp Maier, Harald Welte
|
||||
@@ -31,129 +31,249 @@ operation with pySim-shell.
|
||||
from typing import List, Dict, Optional
|
||||
from Cryptodome.Cipher import AES
|
||||
from osmocom.utils import h2b, b2h
|
||||
from pySim.log import PySimLogger
|
||||
|
||||
import os
|
||||
import abc
|
||||
import csv
|
||||
import logging
|
||||
import yaml
|
||||
import argparse
|
||||
|
||||
log = PySimLogger.get(__name__)
|
||||
|
||||
card_key_providers = [] # type: List['CardKeyProvider']
|
||||
|
||||
# well-known groups of columns relate to a given functionality. This avoids having
|
||||
# to specify the same transport key N number of times, if the same key is used for multiple
|
||||
# fields of one group, like KIC+KID+KID of one SD.
|
||||
CRYPT_GROUPS = {
|
||||
'UICC_SCP02': ['UICC_SCP02_KIC1', 'UICC_SCP02_KID1', 'UICC_SCP02_KIK1'],
|
||||
'UICC_SCP03': ['UICC_SCP03_KIC1', 'UICC_SCP03_KID1', 'UICC_SCP03_KIK1'],
|
||||
'SCP03_ISDR': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDR', 'SCP03_DEK_ISDR'],
|
||||
'SCP03_ISDA': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDA', 'SCP03_DEK_ISDA'],
|
||||
'SCP03_ECASD': ['SCP03_ENC_ECASD', 'SCP03_MAC_ECASD', 'SCP03_DEK_ECASD'],
|
||||
class CardKeyFieldCryptor:
|
||||
"""
|
||||
A Card key field encryption class that may be used by Card key provider implementations to add support for
|
||||
a column-based encryption to protect sensitive material (cryptographic key material, ADM keys, etc.).
|
||||
The sensitive material is encrypted using a "key-encryption key", occasionally also known as "transport key"
|
||||
before it is stored into a file or database (see also GSMA FS.28). The "transport key" is then used to decrypt
|
||||
the key material on demand.
|
||||
"""
|
||||
|
||||
# well-known groups of columns relate to a given functionality. This avoids having
|
||||
# to specify the same transport key N number of times, if the same key is used for multiple
|
||||
# fields of one group, like KIC+KID+KID of one SD.
|
||||
__CRYPT_GROUPS = {
|
||||
'UICC_SCP02': ['UICC_SCP02_KIC1', 'UICC_SCP02_KID1', 'UICC_SCP02_KIK1'],
|
||||
'UICC_SCP03': ['UICC_SCP03_KIC1', 'UICC_SCP03_KID1', 'UICC_SCP03_KIK1'],
|
||||
'SCP03_ISDR': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDR', 'SCP03_DEK_ISDR'],
|
||||
'SCP03_ISDA': ['SCP03_ENC_ISDA', 'SCP03_MAC_ISDA', 'SCP03_DEK_ISDA'],
|
||||
'SCP03_ECASD': ['SCP03_ENC_ECASD', 'SCP03_MAC_ECASD', 'SCP03_DEK_ECASD'],
|
||||
}
|
||||
|
||||
class CardKeyProvider(abc.ABC):
|
||||
"""Base class, not containing any concrete implementation."""
|
||||
|
||||
VALID_KEY_FIELD_NAMES = ['ICCID', 'EID', 'IMSI' ]
|
||||
|
||||
# check input parameters, but do nothing concrete yet
|
||||
def _verify_get_data(self, fields: List[str] = [], key: str = 'ICCID', value: str = "") -> Dict[str, str]:
|
||||
"""Verify multiple fields for identified card.
|
||||
|
||||
Args:
|
||||
fields : list of valid field names such as 'ADM1', 'PIN1', ... which are to be obtained
|
||||
key : look-up key to identify card data, such as 'ICCID'
|
||||
value : value for look-up key to identify card data
|
||||
Returns:
|
||||
dictionary of {field, value} strings for each requested field from 'fields'
|
||||
"""
|
||||
|
||||
if key not in self.VALID_KEY_FIELD_NAMES:
|
||||
raise ValueError("Key field name '%s' is not a valid field name, valid field names are: %s" %
|
||||
(key, str(self.VALID_KEY_FIELD_NAMES)))
|
||||
|
||||
return {}
|
||||
|
||||
def get_field(self, field: str, key: str = 'ICCID', value: str = "") -> Optional[str]:
|
||||
"""get a single field from CSV file using a specified key/value pair"""
|
||||
fields = [field]
|
||||
result = self.get(fields, key, value)
|
||||
return result.get(field)
|
||||
|
||||
@abc.abstractmethod
|
||||
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
|
||||
"""Get multiple card-individual fields for identified card.
|
||||
|
||||
Args:
|
||||
fields : list of valid field names such as 'ADM1', 'PIN1', ... which are to be obtained
|
||||
key : look-up key to identify card data, such as 'ICCID'
|
||||
value : value for look-up key to identify card data
|
||||
Returns:
|
||||
dictionary of {field, value} strings for each requested field from 'fields'
|
||||
"""
|
||||
|
||||
|
||||
class CardKeyProviderCsv(CardKeyProvider):
|
||||
"""Card key provider implementation that allows to query against a specified CSV file.
|
||||
Supports column-based encryption as it is generally a bad idea to store cryptographic key material in
|
||||
plaintext. Instead, the key material should be encrypted by a "key-encryption key", occasionally also
|
||||
known as "transport key" (see GSMA FS.28)."""
|
||||
IV = b'\x23' * 16
|
||||
csv_file = None
|
||||
filename = None
|
||||
|
||||
def __init__(self, filename: str, transport_keys: dict):
|
||||
"""
|
||||
Args:
|
||||
filename : file name (path) of CSV file containing card-individual key/data
|
||||
transport_keys : a dict indexed by field name, whose values are hex-encoded AES keys for the
|
||||
respective field (column) of the CSV. This is done so that different fields
|
||||
(columns) can use different transport keys, which is strongly recommended by
|
||||
GSMA FS.28
|
||||
"""
|
||||
self.csv_file = open(filename, 'r')
|
||||
if not self.csv_file:
|
||||
raise RuntimeError("Could not open CSV file '%s'" % filename)
|
||||
self.filename = filename
|
||||
self.transport_keys = self.process_transport_keys(transport_keys)
|
||||
__IV = b'\x23' * 16
|
||||
|
||||
@staticmethod
|
||||
def process_transport_keys(transport_keys: dict):
|
||||
def __dict_keys_to_upper(d: dict) -> dict:
|
||||
return {k.upper():v for k,v in d.items()}
|
||||
|
||||
@staticmethod
|
||||
def __process_transport_keys(transport_keys: dict, crypt_groups: dict):
|
||||
"""Apply a single transport key to multiple fields/columns, if the name is a group."""
|
||||
new_dict = {}
|
||||
for name, key in transport_keys.items():
|
||||
if name in CRYPT_GROUPS:
|
||||
for field in CRYPT_GROUPS[name]:
|
||||
if name in crypt_groups:
|
||||
for field in crypt_groups[name]:
|
||||
new_dict[field] = key
|
||||
else:
|
||||
new_dict[name] = key
|
||||
return new_dict
|
||||
|
||||
def _decrypt_field(self, field_name: str, encrypted_val: str) -> str:
|
||||
"""decrypt a single field, if we have a transport key for the field of that name."""
|
||||
if not field_name in self.transport_keys:
|
||||
def __init__(self, transport_keys: dict):
|
||||
"""
|
||||
Create new field encryptor/decryptor object and set transport keys, usually one for each column. In some cases
|
||||
it is also possible to use a single key for multiple columns (see also __CRYPT_GROUPS)
|
||||
|
||||
Args:
|
||||
transport_keys : a dict indexed by field name, whose values are hex-encoded AES keys for the
|
||||
respective field (column) of the CSV. This is done so that different fields
|
||||
(columns) can use different transport keys, which is strongly recommended by
|
||||
GSMA FS.28
|
||||
"""
|
||||
self.transport_keys = self.__process_transport_keys(self.__dict_keys_to_upper(transport_keys),
|
||||
self.__CRYPT_GROUPS)
|
||||
for name, key in self.transport_keys.items():
|
||||
log.debug("Encrypting/decrypting field %s using AES key %s" % (name, key))
|
||||
|
||||
def decrypt_field(self, field_name: str, encrypted_val: str) -> str:
|
||||
"""
|
||||
Decrypt a single field. The decryption is only applied if we have a transport key is known under the provided
|
||||
field name, otherwise the field is treated as plaintext and passed through as it is.
|
||||
|
||||
Args:
|
||||
field_name : name of the field to decrypt (used to identify which key to use)
|
||||
encrypted_val : encrypted field value
|
||||
|
||||
Returns:
|
||||
plaintext field value
|
||||
"""
|
||||
if not field_name.upper() in self.transport_keys:
|
||||
return encrypted_val
|
||||
cipher = AES.new(h2b(self.transport_keys[field_name]), AES.MODE_CBC, self.IV)
|
||||
cipher = AES.new(h2b(self.transport_keys[field_name.upper()]), AES.MODE_CBC, self.__IV)
|
||||
return b2h(cipher.decrypt(h2b(encrypted_val)))
|
||||
|
||||
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
|
||||
super()._verify_get_data(fields, key, value)
|
||||
def encrypt_field(self, field_name: str, plaintext_val: str) -> str:
|
||||
"""
|
||||
Encrypt a single field. The encryption is only applied if we have a transport key is known under the provided
|
||||
field name, otherwise the field is treated as non sensitive and passed through as it is.
|
||||
|
||||
Args:
|
||||
field_name : name of the field to decrypt (used to identify which key to use)
|
||||
encrypted_val : encrypted field value
|
||||
|
||||
Returns:
|
||||
plaintext field value
|
||||
"""
|
||||
if not field_name.upper() in self.transport_keys:
|
||||
return plaintext_val
|
||||
cipher = AES.new(h2b(self.transport_keys[field_name.upper()]), AES.MODE_CBC, self.__IV)
|
||||
return b2h(cipher.encrypt(h2b(plaintext_val)))
|
||||
|
||||
class CardKeyProvider(abc.ABC):
|
||||
"""Base class, not containing any concrete implementation."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
|
||||
"""
|
||||
Get multiple card-individual fields for identified card. This method should not fail with an exception in
|
||||
case the entry, columns or even the key column itsself is not found.
|
||||
|
||||
Args:
|
||||
fields : list of valid field names such as 'ADM1', 'PIN1', ... which are to be obtained
|
||||
key : look-up key to identify card data, such as 'ICCID'
|
||||
value : value for look-up key to identify card data
|
||||
Returns:
|
||||
dictionary of {field : value, ...} strings for each requested field from 'fields'. In case nothing is
|
||||
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):
|
||||
return type(self).__name__
|
||||
|
||||
class CardKeyProviderCsv(CardKeyProvider):
|
||||
"""Card key provider implementation that allows to query against a specified CSV file."""
|
||||
|
||||
def __init__(self, csv_filename: str, transport_keys: dict):
|
||||
"""
|
||||
Args:
|
||||
csv_filename : file name (path) of CSV file containing card-individual key/data
|
||||
transport_keys : (see class CardKeyFieldCryptor)
|
||||
"""
|
||||
log.info("Using CSV file as card key data source: %s" % csv_filename)
|
||||
self.csv_file = open(csv_filename, 'r')
|
||||
if not self.csv_file:
|
||||
raise RuntimeError("Could not open CSV file '%s'" % csv_filename)
|
||||
self.csv_filename = csv_filename
|
||||
self.crypt = CardKeyFieldCryptor(transport_keys)
|
||||
|
||||
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
|
||||
self.csv_file.seek(0)
|
||||
cr = csv.DictReader(self.csv_file)
|
||||
if not cr:
|
||||
raise RuntimeError(
|
||||
"Could not open DictReader for CSV-File '%s'" % self.filename)
|
||||
raise RuntimeError("Could not open DictReader for CSV-File '%s'" % self.csv_filename)
|
||||
cr.fieldnames = [field.upper() for field in cr.fieldnames]
|
||||
|
||||
rc = {}
|
||||
if key not in cr.fieldnames:
|
||||
return None
|
||||
return_dict = {}
|
||||
for row in cr:
|
||||
if row[key] == value:
|
||||
for f in fields:
|
||||
if f in row:
|
||||
rc.update({f: self._decrypt_field(f, row[f])})
|
||||
return_dict.update({f: self.crypt.decrypt_field(f, row[f])})
|
||||
else:
|
||||
raise RuntimeError("CSV-File '%s' lacks column '%s'" %
|
||||
(self.filename, f))
|
||||
return rc
|
||||
raise RuntimeError("CSV-File '%s' lacks column '%s'" % (self.csv_filename, f))
|
||||
if return_dict == {}:
|
||||
return None
|
||||
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, transport_keys: dict):
|
||||
"""
|
||||
Args:
|
||||
config_filename : file name (path) of CSV file containing card-individual key/data
|
||||
transport_keys : (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 = CardKeyFieldCryptor(transport_keys)
|
||||
|
||||
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):
|
||||
"""Register a new card key provider.
|
||||
@@ -163,11 +283,10 @@ def card_key_provider_register(provider: CardKeyProvider, provider_list=card_key
|
||||
provider_list : override the list of providers from the global default
|
||||
"""
|
||||
if not isinstance(provider, CardKeyProvider):
|
||||
raise ValueError("provider is not a card data provier")
|
||||
raise ValueError("provider is not a card data provider")
|
||||
provider_list.append(provider)
|
||||
|
||||
|
||||
def card_key_provider_get(fields, key: str, value: str, provider_list=card_key_providers) -> Dict[str, str]:
|
||||
def card_key_provider_get(fields: list[str], key: str, value: str, provider_list=card_key_providers) -> Dict[str, str]:
|
||||
"""Query all registered card data providers for card-individual [key] data.
|
||||
|
||||
Args:
|
||||
@@ -178,17 +297,20 @@ def card_key_provider_get(fields, key: str, value: str, provider_list=card_key_p
|
||||
Returns:
|
||||
dictionary of {field, value} strings for each requested field from 'fields'
|
||||
"""
|
||||
key = key.upper()
|
||||
fields = [f.upper() for f in fields]
|
||||
for p in provider_list:
|
||||
if not isinstance(p, CardKeyProvider):
|
||||
raise ValueError(
|
||||
"provider list contains element which is not a card data provier")
|
||||
raise ValueError("Provider list contains element which is not a card data provider")
|
||||
log.debug("Searching for card key data (key=%s, value=%s, provider=%s)" % (key, value, str(p)))
|
||||
result = p.get(fields, key, value)
|
||||
if result:
|
||||
log.debug("Found card data: %s" % (str(result)))
|
||||
return result
|
||||
return {}
|
||||
|
||||
raise ValueError("Unable to find card key data (key=%s, value=%s, fields=%s)" % (key, value, str(fields)))
|
||||
|
||||
def card_key_provider_get_field(field: str, key: str, value: str, provider_list=card_key_providers) -> Optional[str]:
|
||||
def card_key_provider_get_field(field: str, key: str, value: str, provider_list=card_key_providers) -> str:
|
||||
"""Query all registered card data providers for a single field.
|
||||
|
||||
Args:
|
||||
@@ -199,11 +321,29 @@ def card_key_provider_get_field(field: str, key: str, value: str, provider_list=
|
||||
Returns:
|
||||
dictionary of {field, value} strings for the requested field
|
||||
"""
|
||||
for p in provider_list:
|
||||
if not isinstance(p, CardKeyProvider):
|
||||
raise ValueError(
|
||||
"provider list contains element which is not a card data provier")
|
||||
result = p.get_field(field, key, value)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
fields = [field]
|
||||
result = card_key_provider_get(fields, key, value, card_key_providers)
|
||||
return result.get(field.upper())
|
||||
|
||||
def argparse_add_card_key_provider_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)
|
||||
card_key_group.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)
|
||||
card_key_group.add_argument('--csv-column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
|
||||
help=argparse.SUPPRESS, dest='column_key')
|
||||
|
||||
def init_card_key_provider(opts: argparse.Namespace):
|
||||
"""Initialize card key provider depending on the user provided commandline options"""
|
||||
column_keys = {}
|
||||
for par in opts.column_key:
|
||||
name, key = par.split(':')
|
||||
column_keys[name] = key
|
||||
if os.path.isfile(os.path.expanduser(opts.csv)):
|
||||
card_key_provider_register(CardKeyProviderCsv(os.path.expanduser(opts.csv), column_keys))
|
||||
if os.path.isfile(os.path.expanduser(opts.pgsql)):
|
||||
card_key_provider_register(CardKeyProviderPgsql(os.path.expanduser(opts.pgsql), column_keys))
|
||||
|
||||
@@ -112,6 +112,8 @@ class UiccCardBase(SimCardBase):
|
||||
def probe(self) -> bool:
|
||||
# EF.DIR is a mandatory EF on all ICCIDs; however it *may* also exist on a TS 51.011 SIM
|
||||
ef_dir = EF_DIR()
|
||||
# select MF first
|
||||
self.file_exists("3f00")
|
||||
return self.file_exists(ef_dir.fid)
|
||||
|
||||
def read_aids(self) -> List[Hexstr]:
|
||||
|
||||
10
pySim/cat.py
10
pySim/cat.py
@@ -2,7 +2,7 @@
|
||||
mainly) ETSI TS 102 223, ETSI TS 101 220 and USIM Application Toolkit (SAT)
|
||||
as described in 3GPP TS 31.111."""
|
||||
|
||||
# (C) 2021-2024 by Harald Welte <laforge@osmocom.org>
|
||||
# (C) 2021-2022 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
|
||||
@@ -316,19 +316,19 @@ class FileList(COMPR_TLV_IE, tag=0x92):
|
||||
_construct = Struct('number_of_files'/Int8ub,
|
||||
'files'/GreedyRange(FileId))
|
||||
|
||||
# TS 102 223 Secton 8.19
|
||||
# TS 102 223 Section 8.19
|
||||
class LocationInformation(COMPR_TLV_IE, tag=0x93):
|
||||
pass
|
||||
|
||||
# TS 102 223 Secton 8.20
|
||||
# TS 102 223 Section 8.20
|
||||
class IMEI(COMPR_TLV_IE, tag=0x94):
|
||||
_construct = BcdAdapter(GreedyBytes)
|
||||
|
||||
# TS 102 223 Secton 8.21
|
||||
# TS 102 223 Section 8.21
|
||||
class HelpRequest(COMPR_TLV_IE, tag=0x95):
|
||||
pass
|
||||
|
||||
# TS 102 223 Secton 8.22
|
||||
# TS 102 223 Section 8.22
|
||||
class NetworkMeasurementResults(COMPR_TLV_IE, tag=0x96):
|
||||
_construct = BcdAdapter(GreedyBytes)
|
||||
|
||||
|
||||
@@ -128,10 +128,10 @@ class EF_AD(TransparentEF):
|
||||
cell_test = 0x04
|
||||
|
||||
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)
|
||||
self._construct = Struct(
|
||||
# Byte 1: Display Condition
|
||||
# Byte 1: MS operation mode
|
||||
'ms_operation_mode'/Enum(Byte, self.OP_MODE),
|
||||
# Bytes 2-3: Additional information
|
||||
'additional_info'/Bytes(2),
|
||||
|
||||
@@ -141,7 +141,7 @@ class SimCardCommands:
|
||||
Returns:
|
||||
Tuple of (decoded_data, sw)
|
||||
"""
|
||||
cmd = cmd_constr.build(cmd_data) if cmd_data else ''
|
||||
cmd = cmd_constr.build(cmd_data) if cmd_data else b''
|
||||
lc = i2h([len(cmd)]) if cmd_data else ''
|
||||
le = '00' if resp_constr else ''
|
||||
pdu = ''.join([cla, ins, p1, p2, lc, b2h(cmd), le])
|
||||
@@ -285,7 +285,7 @@ class SimCardCommands:
|
||||
return self.send_apdu_checksw(self.cla_byte + "a40304")
|
||||
|
||||
def select_adf(self, aid: Hexstr) -> ResTuple:
|
||||
"""Execute SELECT a given Applicaiton ADF.
|
||||
"""Execute SELECT a given Application ADF.
|
||||
|
||||
Args:
|
||||
aid : application identifier as hex string
|
||||
@@ -577,7 +577,7 @@ class SimCardCommands:
|
||||
|
||||
Args:
|
||||
rand : 16 byte random data as hex string (RAND)
|
||||
autn : 8 byte Autentication Token (AUTN)
|
||||
autn : 8 byte Authentication Token (AUTN)
|
||||
context : 16 byte random data ('3g' or 'gsm')
|
||||
"""
|
||||
# 3GPP TS 31.102 Section 7.1.2.1
|
||||
|
||||
@@ -54,6 +54,8 @@ def compile_asn1_subdir(subdir_name:str, codec='der'):
|
||||
__ver = sys.version_info
|
||||
if (__ver.major, __ver.minor) >= (3, 9):
|
||||
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 += "\n"
|
||||
#else:
|
||||
|
||||
@@ -73,7 +73,7 @@ class BspAlgoCrypt(BspAlgo, abc.ABC):
|
||||
block_nr = self.block_nr
|
||||
ciphertext = self._encrypt(padded_data)
|
||||
logger.debug("encrypt(block_nr=%u, s_enc=%s, plaintext=%s, padded=%s) -> %s",
|
||||
block_nr, b2h(self.s_enc), b2h(data), b2h(padded_data), b2h(ciphertext))
|
||||
block_nr, b2h(self.s_enc)[:20], b2h(data)[:20], b2h(padded_data)[:20], b2h(ciphertext)[:20])
|
||||
return ciphertext
|
||||
|
||||
def decrypt(self, data:bytes) -> bytes:
|
||||
@@ -149,10 +149,20 @@ class BspAlgoMac(BspAlgo, abc.ABC):
|
||||
temp_data = self.mac_chain + tag_and_length + data
|
||||
old_mcv = self.mac_chain
|
||||
c_mac = self._auth(temp_data)
|
||||
|
||||
# DEBUG: Show MAC computation details
|
||||
logger.debug(f"MAC_DEBUG: tag=0x{tag:02x}, lcc={lcc}")
|
||||
logger.debug(f"MAC_DEBUG: tag_and_length: {tag_and_length.hex()}")
|
||||
logger.debug(f"MAC_DEBUG: mac_chain[:20]: {old_mcv[:20].hex()}")
|
||||
logger.debug(f"MAC_DEBUG: temp_data[:20]: {temp_data[:20].hex()}")
|
||||
logger.debug(f"MAC_DEBUG: c_mac: {c_mac.hex()}")
|
||||
|
||||
# The output data is computed by concatenating the following data: the tag, the final length, the result of step 2 and the C-MAC value.
|
||||
ret = tag_and_length + data + c_mac
|
||||
logger.debug(f"MAC_DEBUG: final_output[:20]: {ret[:20].hex()}")
|
||||
|
||||
logger.debug("auth(tag=0x%x, mcv=%s, s_mac=%s, plaintext=%s, temp=%s) -> %s",
|
||||
tag, b2h(old_mcv), b2h(self.s_mac), b2h(data), b2h(temp_data), b2h(ret))
|
||||
tag, b2h(old_mcv)[:20], b2h(self.s_mac)[:20], b2h(data)[:20], b2h(temp_data)[:20], b2h(ret)[:20])
|
||||
return ret
|
||||
|
||||
def verify(self, ciphertext: bytes) -> bool:
|
||||
@@ -204,6 +214,11 @@ def bsp_key_derivation(shared_secret: bytes, key_type: int, key_length: int, hos
|
||||
s_enc = out[l:2*l]
|
||||
s_mac = out[l*2:3*l]
|
||||
|
||||
logger.debug(f"BSP_KDF_DEBUG: kdf_out = {b2h(out)}")
|
||||
logger.debug(f"BSP_KDF_DEBUG: initial_mcv = {b2h(initial_mac_chaining_value)}")
|
||||
logger.debug(f"BSP_KDF_DEBUG: s_enc = {b2h(s_enc)}")
|
||||
logger.debug(f"BSP_KDF_DEBUG: s_mac = {b2h(s_mac)}")
|
||||
|
||||
return s_enc, s_mac, initial_mac_chaining_value
|
||||
|
||||
|
||||
@@ -231,9 +246,21 @@ class BspInstance:
|
||||
"""Encrypt + MAC a single plaintext TLV. Returns the protected ciphertext."""
|
||||
assert tag <= 255
|
||||
assert len(plaintext) <= self.max_payload_size
|
||||
logger.debug("encrypt_and_mac_one(tag=0x%x, plaintext=%s)", tag, b2h(plaintext))
|
||||
|
||||
# DEBUG: Show what we're processing
|
||||
logger.debug(f"BSP_DEBUG: encrypt_and_mac_one(tag=0x{tag:02x}, plaintext_len={len(plaintext)})")
|
||||
logger.debug(f"BSP_DEBUG: plaintext[:20]: {plaintext[:20].hex()}")
|
||||
logger.debug(f"BSP_DEBUG: s_enc[:20]: {self.c_algo.s_enc[:20].hex()}")
|
||||
logger.debug(f"BSP_DEBUG: s_mac[:20]: {self.m_algo.s_mac[:20].hex()}")
|
||||
|
||||
logger.debug("encrypt_and_mac_one(tag=0x%x, plaintext=%s)", tag, b2h(plaintext)[:20])
|
||||
ciphered = self.c_algo.encrypt(plaintext)
|
||||
logger.debug(f"BSP_DEBUG: ciphered[:20]: {ciphered[:20].hex()}")
|
||||
|
||||
maced = self.m_algo.auth(tag, ciphered)
|
||||
logger.debug(f"BSP_DEBUG: final_result[:20]: {maced[:20].hex()}")
|
||||
logger.debug(f"BSP_DEBUG: final_result_len: {len(maced)}")
|
||||
|
||||
return maced
|
||||
|
||||
def encrypt_and_mac(self, tag: int, plaintext:bytes) -> List[bytes]:
|
||||
@@ -255,7 +282,7 @@ class BspInstance:
|
||||
def mac_only_one(self, tag: int, plaintext: bytes) -> bytes:
|
||||
"""MAC a single plaintext TLV. Returns the protected ciphertext."""
|
||||
assert tag <= 255
|
||||
assert len(plaintext) < self.max_payload_size
|
||||
assert len(plaintext) <= self.max_payload_size
|
||||
maced = self.m_algo.auth(tag, plaintext)
|
||||
# The data block counter for ICV calculation is incremented also for each segment with C-MAC only.
|
||||
self.c_algo.block_nr += 1
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
from datetime import datetime
|
||||
import time
|
||||
@@ -27,7 +33,7 @@ logger.setLevel(logging.DEBUG)
|
||||
|
||||
class param:
|
||||
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."""
|
||||
@classmethod
|
||||
def _encode(cls, data):
|
||||
@@ -40,7 +46,7 @@ class param:
|
||||
|
||||
@classmethod
|
||||
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)))
|
||||
|
||||
@classmethod
|
||||
@@ -53,7 +59,7 @@ class param:
|
||||
@classmethod
|
||||
def verify_decoded(cls, 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)))
|
||||
if len(data) == 19:
|
||||
decimal_part = data
|
||||
@@ -116,17 +122,19 @@ class param:
|
||||
pass
|
||||
|
||||
class Es2PlusApiFunction(JsonHttpApiFunction):
|
||||
"""Base classs for representing an ES2+ API Function."""
|
||||
"""Base class for representing an ES2+ API Function."""
|
||||
pass
|
||||
|
||||
# ES2+ DownloadOrder function (SGP.22 section 5.3.1)
|
||||
class DownloadOrder(Es2PlusApiFunction):
|
||||
path = '/gsma/rsp2/es2plus/downloadOrder'
|
||||
input_params = {
|
||||
'header': JsonRequestHeader,
|
||||
'eid': param.Eid,
|
||||
'iccid': param.Iccid,
|
||||
'profileType': param.ProfileType
|
||||
}
|
||||
input_mandatory = ['header']
|
||||
output_params = {
|
||||
'header': JsonResponseHeader,
|
||||
'iccid': param.Iccid,
|
||||
@@ -137,6 +145,7 @@ class DownloadOrder(Es2PlusApiFunction):
|
||||
class ConfirmOrder(Es2PlusApiFunction):
|
||||
path = '/gsma/rsp2/es2plus/confirmOrder'
|
||||
input_params = {
|
||||
'header': JsonRequestHeader,
|
||||
'iccid': param.Iccid,
|
||||
'eid': param.Eid,
|
||||
'matchingId': param.MatchingId,
|
||||
@@ -144,7 +153,7 @@ class ConfirmOrder(Es2PlusApiFunction):
|
||||
'smdsAddress': param.SmdsAddress,
|
||||
'releaseFlag': param.ReleaseFlag,
|
||||
}
|
||||
input_mandatory = ['iccid', 'releaseFlag']
|
||||
input_mandatory = ['header', 'iccid', 'releaseFlag']
|
||||
output_params = {
|
||||
'header': JsonResponseHeader,
|
||||
'eid': param.Eid,
|
||||
@@ -157,12 +166,13 @@ class ConfirmOrder(Es2PlusApiFunction):
|
||||
class CancelOrder(Es2PlusApiFunction):
|
||||
path = '/gsma/rsp2/es2plus/cancelOrder'
|
||||
input_params = {
|
||||
'header': JsonRequestHeader,
|
||||
'iccid': param.Iccid,
|
||||
'eid': param.Eid,
|
||||
'matchingId': param.MatchingId,
|
||||
'finalProfileStatusIndicator': param.FinalProfileStatusIndicator,
|
||||
}
|
||||
input_mandatory = ['finalProfileStatusIndicator', 'iccid']
|
||||
input_mandatory = ['header', 'finalProfileStatusIndicator', 'iccid']
|
||||
output_params = {
|
||||
'header': JsonResponseHeader,
|
||||
}
|
||||
@@ -172,9 +182,10 @@ class CancelOrder(Es2PlusApiFunction):
|
||||
class ReleaseProfile(Es2PlusApiFunction):
|
||||
path = '/gsma/rsp2/es2plus/releaseProfile'
|
||||
input_params = {
|
||||
'header': JsonRequestHeader,
|
||||
'iccid': param.Iccid,
|
||||
}
|
||||
input_mandatory = ['iccid']
|
||||
input_mandatory = ['header', 'iccid']
|
||||
output_params = {
|
||||
'header': JsonResponseHeader,
|
||||
}
|
||||
@@ -184,6 +195,7 @@ class ReleaseProfile(Es2PlusApiFunction):
|
||||
class HandleDownloadProgressInfo(Es2PlusApiFunction):
|
||||
path = '/gsma/rsp2/es2plus/handleDownloadProgressInfo'
|
||||
input_params = {
|
||||
'header': JsonRequestHeader,
|
||||
'eid': param.Eid,
|
||||
'iccid': param.Iccid,
|
||||
'profileType': param.ProfileType,
|
||||
@@ -192,10 +204,9 @@ class HandleDownloadProgressInfo(Es2PlusApiFunction):
|
||||
'notificationPointStatus': param.NotificationPointStatus,
|
||||
'resultData': param.ResultData,
|
||||
}
|
||||
input_mandatory = ['iccid', 'profileType', 'timestamp', 'notificationPointId', 'notificationPointStatus']
|
||||
input_mandatory = ['header', 'iccid', 'profileType', 'timestamp', 'notificationPointId', 'notificationPointStatus']
|
||||
expected_http_status = 204
|
||||
|
||||
|
||||
class Es2pApiClient:
|
||||
"""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):
|
||||
@@ -206,18 +217,17 @@ class Es2pApiClient:
|
||||
if client_cert:
|
||||
self.session.cert = client_cert
|
||||
|
||||
self.downloadOrder = DownloadOrder(url_prefix, func_req_id, self.session)
|
||||
self.confirmOrder = ConfirmOrder(url_prefix, func_req_id, self.session)
|
||||
self.cancelOrder = CancelOrder(url_prefix, func_req_id, self.session)
|
||||
self.releaseProfile = ReleaseProfile(url_prefix, func_req_id, self.session)
|
||||
self.handleDownloadProgressInfo = HandleDownloadProgressInfo(url_prefix, func_req_id, self.session)
|
||||
self.downloadOrder = JsonHttpApiClient(DownloadOrder(), url_prefix, func_req_id, self.session)
|
||||
self.confirmOrder = JsonHttpApiClient(ConfirmOrder(), url_prefix, func_req_id, self.session)
|
||||
self.cancelOrder = JsonHttpApiClient(CancelOrder(), url_prefix, func_req_id, self.session)
|
||||
self.releaseProfile = JsonHttpApiClient(ReleaseProfile(), url_prefix, func_req_id, self.session)
|
||||
self.handleDownloadProgressInfo = JsonHttpApiClient(HandleDownloadProgressInfo(), url_prefix, func_req_id, self.session)
|
||||
|
||||
def _gen_func_id(self) -> str:
|
||||
"""Generate the next function call id."""
|
||||
self.func_id += 1
|
||||
return 'FCI-%u-%u' % (time.time(), self.func_id)
|
||||
|
||||
|
||||
def call_downloadOrder(self, data: dict) -> dict:
|
||||
"""Perform ES2+ DownloadOrder function (SGP.22 section 5.3.1)."""
|
||||
return self.downloadOrder.call(data, self._gen_func_id())
|
||||
@@ -237,3 +247,116 @@ class Es2pApiClient:
|
||||
def call_handleDownloadProgressInfo(self, data: dict) -> dict:
|
||||
"""Perform ES2+ HandleDownloadProgressInfo function (SGP.22 section 5.3.5)."""
|
||||
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)
|
||||
|
||||
@@ -25,6 +25,9 @@ import pySim.esim.rsp as rsp
|
||||
from pySim.esim.bsp import BspInstance
|
||||
from pySim.esim import PMO
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Given that GSMA RSP uses ASN.1 in a very weird way, we actually cannot encode the full data type before
|
||||
# signing, but we have to build parts of it separately first, then sign that, so we can put the signature
|
||||
# into the same sequence as the signed data. We use the existing pySim TLV code for this.
|
||||
@@ -73,10 +76,11 @@ def gen_replace_session_keys(ppk_enc: bytes, ppk_cmac: bytes, initial_mcv: bytes
|
||||
class ProfileMetadata:
|
||||
"""Representation of Profile metadata. Right now only the mandatory bits are
|
||||
supported, but in general this should follow the StoreMetadataRequest of SGP.22 5.5.3"""
|
||||
def __init__(self, iccid_bin: bytes, spn: str, profile_name: str):
|
||||
def __init__(self, iccid_bin: bytes, spn: str, profile_name: str, profile_class = 'operational'):
|
||||
self.iccid_bin = iccid_bin
|
||||
self.spn = spn
|
||||
self.profile_name = profile_name
|
||||
self.profile_class = profile_class
|
||||
self.icon = None
|
||||
self.icon_type = None
|
||||
self.notifications = []
|
||||
@@ -102,6 +106,14 @@ class ProfileMetadata:
|
||||
'serviceProviderName': self.spn,
|
||||
'profileName': self.profile_name,
|
||||
}
|
||||
if self.profile_class == 'test':
|
||||
smr['profileClass'] = 0
|
||||
elif self.profile_class == 'provisioning':
|
||||
smr['profileClass'] = 1
|
||||
elif self.profile_class == 'operational':
|
||||
smr['profileClass'] = 2
|
||||
else:
|
||||
raise ValueError('Unsupported Profile Class %s' % self.profile_class)
|
||||
if self.icon:
|
||||
smr['icon'] = self.icon
|
||||
smr['iconType'] = self.icon_type
|
||||
@@ -196,8 +208,12 @@ class BoundProfilePackage(ProfilePackage):
|
||||
# 'initialiseSecureChannelRequest'
|
||||
bpp_seq = rsp.asn1.encode('InitialiseSecureChannelRequest', iscr)
|
||||
# firstSequenceOf87
|
||||
logger.debug("BPP_ENCODE_DEBUG: Encrypting ConfigureISDP with BSP keys")
|
||||
logger.debug(f"BPP_ENCODE_DEBUG: BSP S-ENC: {bsp.c_algo.s_enc.hex()}")
|
||||
logger.debug(f"BPP_ENCODE_DEBUG: BSP S-MAC: {bsp.m_algo.s_mac.hex()}")
|
||||
bpp_seq += encode_seq(0xa0, bsp.encrypt_and_mac(0x87, conf_idsp_bin))
|
||||
# sequenceOF88
|
||||
logger.debug("BPP_ENCODE_DEBUG: MAC-only StoreMetadata with BSP keys")
|
||||
bpp_seq += encode_seq(0xa1, bsp.mac_only(0x88, smr_bin))
|
||||
|
||||
if self.ppp: # we have to use session keys
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""GSMA eSIM RSP ES9+ interface according ot SGP.22 v2.5"""
|
||||
"""GSMA eSIM RSP ES9+ interface according to SGP.22 v2.5"""
|
||||
|
||||
# (C) 2024 by Harald Welte <laforge@osmocom.org>
|
||||
#
|
||||
@@ -155,11 +155,11 @@ class Es9pApiClient:
|
||||
if server_cert_verify:
|
||||
self.session.verify = server_cert_verify
|
||||
|
||||
self.initiateAuthentication = InitiateAuthentication(url_prefix, '', self.session)
|
||||
self.authenticateClient = AuthenticateClient(url_prefix, '', self.session)
|
||||
self.getBoundProfilePackage = GetBoundProfilePackage(url_prefix, '', self.session)
|
||||
self.handleNotification = HandleNotification(url_prefix, '', self.session)
|
||||
self.cancelSession = CancelSession(url_prefix, '', self.session)
|
||||
self.initiateAuthentication = JsonHttpApiClient(InitiateAuthentication(), url_prefix, '', self.session)
|
||||
self.authenticateClient = JsonHttpApiClient(AuthenticateClient(), url_prefix, '', self.session)
|
||||
self.getBoundProfilePackage = JsonHttpApiClient(GetBoundProfilePackage(), url_prefix, '', self.session)
|
||||
self.handleNotification = JsonHttpApiClient(HandleNotification(), url_prefix, '', self.session)
|
||||
self.cancelSession = JsonHttpApiClient(CancelSession(), url_prefix, '', self.session)
|
||||
|
||||
def call_initiateAuthentication(self, data: dict) -> dict:
|
||||
return self.initiateAuthentication.call(data)
|
||||
|
||||
@@ -19,8 +19,10 @@ import abc
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
import base64
|
||||
from twisted.web.server import Request
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -131,6 +133,16 @@ class JsonResponseHeader(ApiParam):
|
||||
if status not in ['Executed-Success', 'Executed-WithWarning', 'Failed', 'Expired']:
|
||||
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):
|
||||
pass
|
||||
@@ -149,7 +161,8 @@ class ApiError(Exception):
|
||||
'message': 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.reason_code = sec['reasonCode']
|
||||
self.subject_id = sec['subjectIdentifier']
|
||||
@@ -159,66 +172,119 @@ class ApiError(Exception):
|
||||
return f'{self.status}("{self.subject_code}","{self.reason_code}","{self.subject_id}","{self.message}")'
|
||||
|
||||
class JsonHttpApiFunction(abc.ABC):
|
||||
"""Base classs for representing an HTTP[s] API Function."""
|
||||
# the below class variables are expected to be overridden in derived classes
|
||||
"""Base class for representing an HTTP[s] API Function."""
|
||||
# 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
|
||||
|
||||
# dictionary of input parameters. key is parameter name, value is ApiParam class
|
||||
input_params = {}
|
||||
|
||||
# list of mandatory input parameters
|
||||
input_mandatory = []
|
||||
|
||||
# dictionary of output parameters. key is parameter name, value is ApiParam class
|
||||
output_params = {}
|
||||
|
||||
# list of mandatory output parameters (for successful response)
|
||||
output_mandatory = []
|
||||
|
||||
# list of mandatory output parameters (for failed response)
|
||||
output_mandatory_failed = []
|
||||
|
||||
# expected HTTP status code of the response
|
||||
expected_http_status = 200
|
||||
|
||||
# the HTTP method used (GET, OPTIONS, HEAD, POST, PUT, PATCH or DELETE)
|
||||
http_method = 'POST'
|
||||
|
||||
# additional custom HTTP headers (client requests)
|
||||
extra_http_req_headers = {}
|
||||
|
||||
def __init__(self, url_prefix: str, func_req_id: Optional[str], session: requests.Session):
|
||||
self.url_prefix = url_prefix
|
||||
self.func_req_id = func_req_id
|
||||
self.session = session
|
||||
# additional custom HTTP headers (server responses)
|
||||
extra_http_res_headers = {}
|
||||
|
||||
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."""
|
||||
output = {}
|
||||
if func_call_id:
|
||||
output['header'] = {
|
||||
'functionRequesterIdentifier': self.func_req_id,
|
||||
'functionCallIdentifier': func_call_id
|
||||
}
|
||||
|
||||
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
|
||||
# pySim/esim/http_json_api.py:269:47: E1101: Instance of 'JsonHttpApiFunction' has no 'legacy' member (no-member)
|
||||
# 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:
|
||||
output[p] = p_class.encode(v)
|
||||
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."""
|
||||
output = {}
|
||||
if 'header' in self.output_params:
|
||||
# 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'])
|
||||
output_mandatory = self.output_mandatory
|
||||
|
||||
if output['header']['functionExecutionStatus']['status'] not in ['Executed-Success','Executed-WithWarning']:
|
||||
raise ApiError(output['header']['functionExecutionStatus'])
|
||||
# we can only expect mandatory parameters to be present in case of successful execution
|
||||
for p in self.output_mandatory:
|
||||
if p == 'header':
|
||||
continue
|
||||
# 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():
|
||||
@@ -230,30 +296,195 @@ class JsonHttpApiFunction(abc.ABC):
|
||||
output[p] = p_class.decode(v)
|
||||
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]:
|
||||
"""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
|
||||
is returned as json-deserialized dict."""
|
||||
url = self.url_prefix + self.path
|
||||
encoded = json.dumps(self.encode(data, func_call_id))
|
||||
"""
|
||||
Make an API call to the HTTP API endpoint represented by this object. Input data is passed in `data` as
|
||||
json-serializable fields. `data` may also contain additional parameters required for URL rewriting (see
|
||||
rewrite_url in class JsonHttpApiFunction). Output data is returned as json-deserialized dict.
|
||||
|
||||
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 = {
|
||||
'Content-Type': 'application/json',
|
||||
'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))
|
||||
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: %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)
|
||||
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)
|
||||
if not response.headers.get('X-Admin-Protocol', 'gsma/rsp/v2.unknown').startswith('gsma/rsp/v2.'):
|
||||
raise HttpHeaderError(response)
|
||||
|
||||
# Decode response and return the result back to the caller
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -90,13 +90,61 @@ class RspSessionState:
|
||||
# FIXME: how to add the public key from smdp_otpk to an instance of EllipticCurvePrivateKey?
|
||||
del state['_smdp_otsk']
|
||||
del state['_smdp_ot_curve']
|
||||
# automatically recover all the remainig state
|
||||
# automatically recover all the remaining state
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
class RspSessionStore(shelve.DbfilenameShelf):
|
||||
"""A derived class as wrapper around the database-backed non-volatile storage 'shelve', in case we might
|
||||
need to extend it in the future. We use it to store RspSessionState objects indexed by transactionId."""
|
||||
class RspSessionStore:
|
||||
"""A wrapper around the database-backed storage 'shelve' for storing RspSessionState objects.
|
||||
Can be configured to use either file-based storage or in-memory storage.
|
||||
We use it to store RspSessionState objects indexed by transactionId."""
|
||||
|
||||
def __init__(self, filename: Optional[str] = None, in_memory: bool = False):
|
||||
self._in_memory = in_memory
|
||||
|
||||
if in_memory:
|
||||
self._shelf = shelve.Shelf(dict())
|
||||
else:
|
||||
if filename is None:
|
||||
raise ValueError("filename is required for file-based session store")
|
||||
self._shelf = shelve.open(filename)
|
||||
|
||||
# dunder magic
|
||||
def __getitem__(self, key):
|
||||
return self._shelf[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._shelf[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self._shelf[key]
|
||||
|
||||
def __contains__(self, key):
|
||||
return key in self._shelf
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._shelf)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._shelf)
|
||||
|
||||
# everything else
|
||||
def __getattr__(self, name):
|
||||
"""Delegate attribute access to the underlying shelf object."""
|
||||
return getattr(self._shelf, name)
|
||||
|
||||
def close(self):
|
||||
"""Close the session store."""
|
||||
if hasattr(self._shelf, 'close'):
|
||||
self._shelf.close()
|
||||
if self._in_memory:
|
||||
# For in-memory store, clear the reference
|
||||
self._shelf = None
|
||||
|
||||
def sync(self):
|
||||
"""Synchronize the cache with the underlying storage."""
|
||||
if hasattr(self._shelf, 'sync'):
|
||||
self._shelf.sync()
|
||||
|
||||
def extract_euiccSigned1(authenticateServerResponse: bytes) -> bytes:
|
||||
"""Extract the raw, DER-encoded binary euiccSigned1 field from the given AuthenticateServerResponse. This
|
||||
|
||||
@@ -57,7 +57,7 @@ class NonMatch(Match):
|
||||
cur = 0
|
||||
for match in l:
|
||||
if match.a != match.b:
|
||||
return ValueError('only works for equal-offset matches')
|
||||
raise ValueError('only works for equal-offset matches')
|
||||
assert match.a >= cur
|
||||
nm_len = match.a - cur
|
||||
if nm_len > 0:
|
||||
@@ -151,6 +151,8 @@ class File:
|
||||
self.df_name = None
|
||||
self.fill_pattern = None
|
||||
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
|
||||
if self.template:
|
||||
self.from_template(self.template)
|
||||
@@ -169,6 +171,9 @@ class File:
|
||||
def file_size(self) -> Optional[int]:
|
||||
"""Return the size of the file in bytes."""
|
||||
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
|
||||
elif self.file_type in ['TR', 'BT']:
|
||||
return self._file_size
|
||||
@@ -208,7 +213,7 @@ class File:
|
||||
self.file_type = template.file_type
|
||||
self.fid = template.fid
|
||||
self.sfi = template.sfi
|
||||
self.arr = template.arr.to_bytes(1)
|
||||
self.arr = template.arr.to_bytes(1, 'big')
|
||||
if hasattr(template, 'rec_len'):
|
||||
self.rec_len = template.rec_len
|
||||
else:
|
||||
@@ -252,7 +257,7 @@ class File:
|
||||
fileDescriptor['shortEFID'] = bytes([self.sfi])
|
||||
if self.df_name:
|
||||
fileDescriptor['dfName'] = self.df_name
|
||||
if self.arr and self.arr != self.template.arr.to_bytes(1):
|
||||
if self.arr and self.arr != self.template.arr.to_bytes(1, 'big'):
|
||||
fileDescriptor['securityAttributesReferenced'] = self.arr
|
||||
if self.file_type in ['LF', 'CY']:
|
||||
fdb_dec['file_type'] = 'working_ef'
|
||||
@@ -275,6 +280,8 @@ class File:
|
||||
elif self.file_type in ['MF', 'DF', 'ADF']:
|
||||
fdb_dec['file_type'] = 'df'
|
||||
fdb_dec['structure'] = 'no_info_given'
|
||||
# pinStatusTemplateDO is mandatory for DF/ADF
|
||||
fileDescriptor['pinStatusTemplateDO'] = self.pstdo
|
||||
# build file descriptor based on above input data
|
||||
fd_dict = {}
|
||||
if len(fdb_dec):
|
||||
@@ -289,7 +296,7 @@ class File:
|
||||
if self.read_and_update_when_deact:
|
||||
spfi |= 0x40 # TS 102 222 Table 5
|
||||
if spfi != 0x00:
|
||||
pefi['specialFileInformation'] = spfi.to_bytes(1)
|
||||
pefi['specialFileInformation'] = spfi.to_bytes(1, 'big')
|
||||
if self.fill_pattern:
|
||||
if not self.fill_pattern_repeat:
|
||||
pefi['fillPattern'] = self.fill_pattern
|
||||
@@ -301,6 +308,8 @@ class File:
|
||||
# desired fill or repeat pattern in the "proprietaryEFInfo" element for the EF in Profiles
|
||||
# downloaded to a V2.2 or earlier eUICC.
|
||||
fileDescriptor['proprietaryEFInfo'] = pefi
|
||||
if self.lcsi:
|
||||
fileDescriptor['lcsi'] = self.lcsi
|
||||
logger.debug("%s: to_fileDescriptor(%s)" % (self, fileDescriptor))
|
||||
return fileDescriptor
|
||||
|
||||
@@ -316,6 +325,12 @@ class File:
|
||||
dfName = fileDescriptor.get('dfName', None)
|
||||
if 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', {})
|
||||
securityAttributesReferenced = fileDescriptor.get('securityAttributesReferenced', None)
|
||||
if securityAttributesReferenced:
|
||||
@@ -325,13 +340,11 @@ class File:
|
||||
fdb_dec = fd_dec['file_descriptor_byte']
|
||||
self.shareable = fdb_dec['shareable']
|
||||
if fdb_dec['file_type'] == 'working_ef':
|
||||
efFileSize = fileDescriptor.get('efFileSize', None)
|
||||
if fd_dec['num_of_rec']:
|
||||
self.nb_rec = fd_dec['num_of_rec']
|
||||
if fd_dec['record_len']:
|
||||
self.rec_len = fd_dec['record_len']
|
||||
if efFileSize:
|
||||
self._file_size = self._decode_file_size(efFileSize)
|
||||
if self.rec_len and self.nb_rec == None:
|
||||
# compute the number of records from file size and record length
|
||||
self.nb_rec = self._file_size // self.rec_len
|
||||
@@ -359,7 +372,7 @@ class File:
|
||||
self.fill_pattern = pefi['fillPattern']
|
||||
self.fill_pattern_repeat = False
|
||||
elif fdb_dec['file_type'] == 'df':
|
||||
# only set it, if an earlier call to from_template() didn't alrady set it, as
|
||||
# only set it, if an earlier call to from_template() didn't already set it, as
|
||||
# the template can differentiate between MF, DF and ADF (unlike FDB)
|
||||
if not self.file_type:
|
||||
self.file_type = 'DF'
|
||||
@@ -428,10 +441,14 @@ class File:
|
||||
elif k == 'fillFileContent':
|
||||
stream.write(v)
|
||||
else:
|
||||
return ValueError("Unknown key '%s' in tuple list" % k)
|
||||
raise ValueError("Unknown key '%s' in tuple list" % k)
|
||||
return stream.getvalue()
|
||||
|
||||
def file_content_to_tuples(self, optimize:bool = True) -> List[Tuple]:
|
||||
def file_content_to_tuples(self, optimize:bool = False) -> List[Tuple]:
|
||||
"""Encode the file contents into a list of fillFileContent / fillFileOffset tuples that can be fed
|
||||
into the asn.1 encoder. If optimize is True, it will try to encode only the differences from the
|
||||
fillFileContent of the profile template. Otherwise, the entire file contents will be encoded
|
||||
as-is."""
|
||||
if not self.file_type in ['TR', 'LF', 'CY', 'BT']:
|
||||
return []
|
||||
if not optimize:
|
||||
@@ -459,6 +476,7 @@ class File:
|
||||
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:
|
||||
@@ -475,7 +493,7 @@ class File:
|
||||
|
||||
class ProfileElement:
|
||||
"""Generic Class representing a Profile Element (PE) within a SAIP Profile. This may be used directly,
|
||||
but ist more likely sub-classed with a specific class for the specific profile element type, like e.g
|
||||
but it's more likely sub-classed with a specific class for the specific profile element type, like e.g
|
||||
ProfileElementHeader, ProfileElementMF, ...
|
||||
"""
|
||||
FILE_BEARING = ['mf', 'cd', 'telecom', 'usim', 'opt-usim', 'isim', 'opt-isim', 'phonebook', 'gsm-access',
|
||||
@@ -488,7 +506,7 @@ class ProfileElement:
|
||||
'genericFileManagement': 'gfm-header',
|
||||
'akaParameter': 'aka-header',
|
||||
'cdmaParameter': 'cdma-header',
|
||||
# note how they couldn't even consistently captialize the 'header' suffix :(
|
||||
# note how they couldn't even consistently capitalize the 'header' suffix :(
|
||||
'application': 'app-Header',
|
||||
'pukCodes': 'puk-Header',
|
||||
'pinCodes': 'pin-Header',
|
||||
@@ -676,13 +694,20 @@ class FsProfileElement(ProfileElement):
|
||||
# this is a template that belongs into the [A]DF of another template
|
||||
# 1) find the PE for the referenced template
|
||||
parent_pe = self.pe_sequence.get_closest_prev_pe_for_templateID(self, template.parent.oid)
|
||||
# 2) resolve te [A]DF that forms the base of that parent PE
|
||||
# 2) resolve the [A]DF that forms the base of that parent PE
|
||||
pe_df = parent_pe.files[template.parent.base_df().pe_name].node
|
||||
self.pe_sequence.cur_df = pe_df
|
||||
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):
|
||||
"""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():
|
||||
self.decoded[k] = f.to_tuples()
|
||||
|
||||
@@ -697,7 +722,7 @@ class FsProfileElement(ProfileElement):
|
||||
self.add_file(file)
|
||||
|
||||
def create_file(self, pename: str) -> File:
|
||||
"""Programatically create a file by its PE-Name."""
|
||||
"""Programmatically create a file by its PE-Name."""
|
||||
template = templates.ProfileTemplateRegistry.get_by_oid(self.templateID)
|
||||
file = File(pename, None, template.files_by_pename.get(pename, None))
|
||||
self.add_file(file)
|
||||
@@ -1033,9 +1058,9 @@ class SecurityDomainKey:
|
||||
self.key_components = key_components
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return 'SdKey(KVN=0x%02x, ID=0x%02x, Usage=%s, Comp=%s)' % (self.key_version_number,
|
||||
return 'SdKey(KVN=0x%02x, ID=0x%02x, Usage=0x%x, Comp=%s)' % (self.key_version_number,
|
||||
self.key_identifier,
|
||||
self.key_usage_qualifier,
|
||||
build_construct(KeyUsageQualifier, self.key_usage_qualifier)[0],
|
||||
repr(self.key_components))
|
||||
|
||||
@classmethod
|
||||
@@ -1054,6 +1079,13 @@ class SecurityDomainKey:
|
||||
'keyVersionNumber': bytes([self.key_version_number]),
|
||||
'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 representing a securityDomain ProfileElement."""
|
||||
type = 'securityDomain'
|
||||
@@ -1068,6 +1100,7 @@ class ProfileElementSD(ProfileElement):
|
||||
def __init__(self, decoded: Optional[dict] = None, **kwargs):
|
||||
super().__init__(decoded, **kwargs)
|
||||
if decoded:
|
||||
self._post_decode()
|
||||
return
|
||||
# provide some reasonable defaults for a MNO-SD
|
||||
self.decoded['instance'] = {
|
||||
@@ -1457,7 +1490,7 @@ class ProfileElementHeader(ProfileElement):
|
||||
iccid: Optional[Hexstr] = '0'*20, profile_type: Optional[str] = None,
|
||||
**kwargs):
|
||||
"""You would usually initialize an instance either with a "decoded" argument (as read from
|
||||
a DER-encoded SAIP file via asn1tools), or [some of] the othe arguments in case you're
|
||||
a DER-encoded SAIP file via asn1tools), or [some of] the other arguments in case you're
|
||||
constructing a Profile Header from scratch.
|
||||
|
||||
Args:
|
||||
@@ -1610,7 +1643,7 @@ class ProfileElementSequence:
|
||||
|
||||
def _rebuild_pes_by_naa(self) -> None:
|
||||
"""rebuild the self.pes_by_naa dict {naa: [ [pe, pe, pe], [pe, pe] ]} form,
|
||||
which basically means for every NAA there's a lsit of instances, and each consists
|
||||
which basically means for every NAA there's a list of instances, and each consists
|
||||
of a list of a list of PEs."""
|
||||
self.pres_by_naa = {}
|
||||
petype_not_naa_related = ['securityDomain', 'rfm', 'application', 'end']
|
||||
@@ -1738,7 +1771,7 @@ class ProfileElementSequence:
|
||||
i += 1
|
||||
|
||||
def get_index_by_pe(self, pe: ProfileElement) -> int:
|
||||
"""Return a list with the indicies of all instances of PEs of petype."""
|
||||
"""Return a list with the indices of all instances of PEs of petype."""
|
||||
ret = []
|
||||
i = 0
|
||||
for cur in self.pe_list:
|
||||
@@ -1759,7 +1792,7 @@ class ProfileElementSequence:
|
||||
self.insert_at_index(idx+1, pe_new)
|
||||
|
||||
def get_index_by_type(self, petype: str) -> List[int]:
|
||||
"""Return a list with the indicies of all instances of PEs of petype."""
|
||||
"""Return a list with the indices of all instances of PEs of petype."""
|
||||
ret = []
|
||||
i = 0
|
||||
for pe in self.pe_list:
|
||||
@@ -1784,10 +1817,9 @@ class ProfileElementSequence:
|
||||
for service in naa.mandatory_services:
|
||||
if service in hdr.decoded['eUICC-Mandatory-services']:
|
||||
del hdr.decoded['eUICC-Mandatory-services'][service]
|
||||
# remove any associaed mandatory filesystem templates
|
||||
# remove any associated mandatory filesystem 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
|
||||
naa_adf_names = []
|
||||
if naa.pe_types[0] in self.pe_by_type:
|
||||
@@ -1830,7 +1862,7 @@ class ProfileElementSequence:
|
||||
return None
|
||||
|
||||
@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."""
|
||||
naa = ProfileElementSequence.naa_for_path(path)
|
||||
if naa:
|
||||
@@ -1863,7 +1895,7 @@ class ProfileElementSequence:
|
||||
return ProfileElementTelecom, ft
|
||||
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
|
||||
either be an existing PE within the sequence, or it will be a newly-allocated PE that is
|
||||
inserted into the sequence."""
|
||||
@@ -1929,7 +1961,10 @@ class ProfileElementSequence:
|
||||
|
||||
|
||||
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,
|
||||
name: Optional[str] = None):
|
||||
self.fid = fid
|
||||
@@ -1984,7 +2019,7 @@ class FsNode:
|
||||
return x
|
||||
|
||||
def walk(self, fn, **kwargs):
|
||||
"""call 'fn(self, **kwargs) for the File."""
|
||||
"""call 'fn(self, ``**kwargs``) for the File."""
|
||||
return [fn(self, **kwargs)]
|
||||
|
||||
class FsNodeEF(FsNode):
|
||||
@@ -2074,7 +2109,7 @@ class FsNodeDF(FsNode):
|
||||
return cur
|
||||
|
||||
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)
|
||||
for c in self.children.values():
|
||||
ret += c.walk(fn, **kwargs)
|
||||
@@ -2088,7 +2123,8 @@ class FsNodeADF(FsNodeDF):
|
||||
super().__init__(fid, parent, file, name)
|
||||
|
||||
def __str__(self):
|
||||
return '%s(%s)' % (self.__class__.__name__, b2h(self.df_name))
|
||||
# self.df_name is usually None for an ADF like ADF.USIM or ADF.ISIM so we need to guard against it
|
||||
return '%s(%s)' % (self.__class__.__name__, b2h(self.df_name) if self.df_name else None)
|
||||
|
||||
class FsNodeMF(FsNodeDF):
|
||||
"""The MF (Master File) in the filesystem hierarchy."""
|
||||
|
||||
120
pySim/esim/saip/batch.py
Normal file
120
pySim/esim/saip/batch.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""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
|
||||
from typing import Generator
|
||||
from pySim.esim.saip.personalization import ConfigurableParameter
|
||||
from pySim.esim.saip import param_source
|
||||
from pySim.esim.saip import ProfileElementSequence
|
||||
|
||||
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
|
||||
|
||||
yield pes
|
||||
203
pySim/esim/saip/param_source.py
Normal file
203
pySim/esim/saip/param_source.py
Normal file
@@ -0,0 +1,203 @@
|
||||
# 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 random
|
||||
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 RandomDigitSource(DecimalRangeSource):
|
||||
"""return a different sequence of random decimal digits each"""
|
||||
name = "random decimal digits"
|
||||
|
||||
def get_next(self, csv_row:dict=None):
|
||||
val = random.randint(self.first_value, self.last_value) # TODO secure random source?
|
||||
return self.val_to_digit(val)
|
||||
|
||||
class RandomHexDigitSource(InputExpandingParamSource):
|
||||
"""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
|
||||
|
||||
def get_next(self, csv_row:dict=None):
|
||||
val = random.randbytes(self.num_digits // 2) # TODO secure random source?
|
||||
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
|
||||
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(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(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]),
|
||||
]
|
||||
|
||||
@@ -818,7 +818,7 @@ class FilesIsimOptional(ProfileTemplate):
|
||||
base_path = Path('ADF.ISIM')
|
||||
extends = FilesIsimMandatory
|
||||
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(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]),
|
||||
|
||||
@@ -68,7 +68,7 @@ class CheckBasicStructure(ProfileConstraintChecker):
|
||||
|
||||
def check_optional_ordering(self, pes: ProfileElementSequence):
|
||||
"""Check the ordering of optional PEs following the respective mandatory ones."""
|
||||
# ordering and required depenencies
|
||||
# ordering and required dependencies
|
||||
self._is_after_if_exists(pes,'opt-usim', 'usim')
|
||||
self._is_after_if_exists(pes,'opt-isim', 'isim')
|
||||
self._is_after_if_exists(pes,'gsm-access', 'usim')
|
||||
@@ -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):
|
||||
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):
|
||||
"""Ensure that each PE has a unique identification value."""
|
||||
id_list = [pe.header['identification'] for pe in pes.pe_list if pe.header]
|
||||
|
||||
@@ -149,7 +149,7 @@ class CertificateSet:
|
||||
check_signed(c, self.root_cert)
|
||||
return
|
||||
parent_cert = self.intermediate_certs.get(aki, None)
|
||||
if not aki:
|
||||
if not parent_cert:
|
||||
raise VerifyError('Could not find intermediate certificate for AuthKeyId %s' % b2h(aki))
|
||||
check_signed(c, parent_cert)
|
||||
# if we reach here, we passed (no exception raised)
|
||||
|
||||
@@ -181,7 +181,7 @@ class SeqNumber(BER_TLV_IE, tag=0x80):
|
||||
class NotificationAddress(BER_TLV_IE, tag=0x0c):
|
||||
_construct = Utf8Adapter(GreedyBytes)
|
||||
class Iccid(BER_TLV_IE, tag=0x5a):
|
||||
_construct = BcdAdapter(GreedyBytes)
|
||||
_construct = PaddedBcdAdapter(GreedyBytes)
|
||||
class NotificationMetadata(BER_TLV_IE, tag=0xbf2f, nested=[SeqNumber, ProfileMgmtOperation,
|
||||
NotificationAddress, Iccid]):
|
||||
pass
|
||||
|
||||
@@ -30,6 +30,7 @@ import tempfile
|
||||
import json
|
||||
import abc
|
||||
import inspect
|
||||
import os
|
||||
|
||||
import cmd2
|
||||
from cmd2 import CommandSet, with_default_category
|
||||
@@ -39,7 +40,7 @@ from osmocom.utils import h2b, b2h, is_hex, auto_int, auto_uint8, auto_uint16, i
|
||||
from osmocom.tlv import bertlv_parse_one
|
||||
from osmocom.construct import filter_dict, parse_construct, build_construct
|
||||
|
||||
from pySim.utils import sw_match
|
||||
from pySim.utils import sw_match, decomposeATR
|
||||
from pySim.jsonpath import js_path_modify
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.exceptions import SwMatchError
|
||||
@@ -86,7 +87,7 @@ class CardFile:
|
||||
self.service = service
|
||||
self.shell_commands = [] # type: List[CommandSet]
|
||||
|
||||
# Note: the basic properties (fid, name, ect.) are verified when
|
||||
# Note: the basic properties (fid, name, etc.) are verified when
|
||||
# the file is attached to a parent file. See method add_file() in
|
||||
# class Card DF
|
||||
|
||||
@@ -266,7 +267,7 @@ class CardFile:
|
||||
def get_profile(self):
|
||||
"""Get the profile associated with this file. If this file does not have any
|
||||
profile assigned, try to find a file above (usually the MF) in the filesystem
|
||||
hirarchy that has a profile assigned
|
||||
hierarchy that has a profile assigned
|
||||
"""
|
||||
|
||||
# If we have a profile set, return it
|
||||
@@ -552,6 +553,85 @@ class CardADF(CardDF):
|
||||
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):
|
||||
"""EF (Entry File) in the smart card filesystem"""
|
||||
|
||||
@@ -657,15 +737,8 @@ class TransparentEF(CardEF):
|
||||
def do_edit_binary_decoded(self, _opts):
|
||||
"""Edit the JSON representation of the EF contents in an editor."""
|
||||
(orig_json, _sw) = self._cmd.lchan.read_binary_dec()
|
||||
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname:
|
||||
filename = '%s/file' % dirname
|
||||
# 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)
|
||||
ef = self._cmd.lchan.selected_file
|
||||
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
|
||||
if edited_json == orig_json:
|
||||
self._cmd.poutput("Data not modified, skipping write")
|
||||
else:
|
||||
@@ -679,7 +752,7 @@ class TransparentEF(CardEF):
|
||||
Args:
|
||||
fid : File Identifier (4 hex digits)
|
||||
sfid : Short File Identifier (2 hex digits, optional)
|
||||
name : Brief name of the file, lik EF_ICCID
|
||||
name : Brief name of the file, like EF_ICCID
|
||||
desc : Description of the file
|
||||
parent : Parent CardFile object within filesystem hierarchy
|
||||
size : tuple of (minimum_size, recommended_size)
|
||||
@@ -959,15 +1032,8 @@ class LinFixedEF(CardEF):
|
||||
def do_edit_record_decoded(self, opts):
|
||||
"""Edit the JSON representation of one record in an editor."""
|
||||
(orig_json, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR)
|
||||
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname:
|
||||
filename = '%s/file' % dirname
|
||||
# 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)
|
||||
ef = self._cmd.lchan.selected_file
|
||||
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
|
||||
if edited_json == orig_json:
|
||||
self._cmd.poutput("Data not modified, skipping write")
|
||||
else:
|
||||
@@ -982,11 +1048,11 @@ class LinFixedEF(CardEF):
|
||||
Args:
|
||||
fid : File Identifier (4 hex digits)
|
||||
sfid : Short File Identifier (2 hex digits, optional)
|
||||
name : Brief name of the file, lik EF_ICCID
|
||||
name : Brief name of the file, like EF_ICCID
|
||||
desc : Description of the file
|
||||
parent : Parent CardFile object within filesystem hierarchy
|
||||
rec_len : Tuple of (minimum_length, recommended_length)
|
||||
leftpad: On write, data must be padded from the left to fit pysical record length
|
||||
leftpad: On write, data must be padded from the left to fit physical record length
|
||||
"""
|
||||
super().__init__(fid=fid, sfid=sfid, name=name, desc=desc, parent=parent, **kwargs)
|
||||
self.rec_len = rec_len
|
||||
@@ -1422,7 +1488,7 @@ class BerTlvEF(CardEF):
|
||||
Args:
|
||||
fid : File Identifier (4 hex digits)
|
||||
sfid : Short File Identifier (2 hex digits, optional)
|
||||
name : Brief name of the file, lik EF_ICCID
|
||||
name : Brief name of the file, like EF_ICCID
|
||||
desc : Description of the file
|
||||
parent : Parent CardFile object within filesystem hierarchy
|
||||
size : tuple of (minimum_size, recommended_size)
|
||||
@@ -1455,7 +1521,7 @@ class BerTlvEF(CardEF):
|
||||
export_str += "delete_all\n"
|
||||
for t in tags:
|
||||
result = lchan.retrieve_data(t)
|
||||
(tag, l, val, remainer) = bertlv_parse_one(h2b(result[0]))
|
||||
(tag, l, val, remainder) = bertlv_parse_one(h2b(result[0]))
|
||||
export_str += ("set_data 0x%02x %s\n" % (t, b2h(val)))
|
||||
return export_str.strip()
|
||||
|
||||
@@ -1495,7 +1561,7 @@ class CardApplication:
|
||||
self.name = name
|
||||
self.adf = adf
|
||||
self.sw = sw or {}
|
||||
# back-reference from ADF to Applicaiton
|
||||
# back-reference from ADF to Application
|
||||
if self.adf:
|
||||
self.aid = aid or self.adf.aid
|
||||
self.adf.application = self
|
||||
@@ -1545,6 +1611,13 @@ class CardModel(abc.ABC):
|
||||
if atr == card_atr:
|
||||
print("Detected CardModel:", cls.__name__)
|
||||
return True
|
||||
# if nothing found try to just compare the Historical Bytes of the ATR
|
||||
card_atr_hb = decomposeATR(card_atr)['hb']
|
||||
for atr in cls._atrs:
|
||||
atr_hb = decomposeATR(atr)['hb']
|
||||
if atr_hb == card_atr_hb:
|
||||
print("Detected CardModel:", cls.__name__)
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@@ -1565,7 +1638,7 @@ class Path:
|
||||
p = p.split('/')
|
||||
elif len(p) and isinstance(p[0], int):
|
||||
p = ['%04x' % x for x in p]
|
||||
# make sure internal representation alwas is uppercase only
|
||||
# make sure internal representation always is uppercase only
|
||||
self.list = [x.upper() for x in p]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -276,7 +276,7 @@ class ListOfSupportedOptions(BER_TLV_IE, tag=0x81):
|
||||
class SupportedKeysForScp03(BER_TLV_IE, tag=0x82):
|
||||
_construct = FlagsEnum(Byte, aes128=0x01, aes192=0x02, aes256=0x04)
|
||||
class SupportedTlsCipherSuitesForScp81(BER_TLV_IE, tag=0x83):
|
||||
_consuruct = GreedyRange(Int16ub)
|
||||
_construct = GreedyRange(Int16ub)
|
||||
class ScpInformation(BER_TLV_IE, tag=0xa0, nested=[ScpType, ListOfSupportedOptions, SupportedKeysForScp03,
|
||||
SupportedTlsCipherSuitesForScp81]):
|
||||
pass
|
||||
@@ -319,7 +319,7 @@ class CurrentSecurityLevel(BER_TLV_IE, tag=0xd3):
|
||||
# GlobalPlatform v2.3.1 Section 11.3.3.1.3
|
||||
class ApplicationAID(BER_TLV_IE, tag=0x4f):
|
||||
_construct = GreedyBytes
|
||||
class ApplicationTemplate(BER_TLV_IE, tag=0x61, ntested=[ApplicationAID]):
|
||||
class ApplicationTemplate(BER_TLV_IE, tag=0x61, nested=[ApplicationAID]):
|
||||
pass
|
||||
class ListOfApplications(BER_TLV_IE, tag=0x2f00, nested=[ApplicationTemplate]):
|
||||
pass
|
||||
@@ -562,14 +562,14 @@ class ADF_SD(CardADF):
|
||||
|
||||
@cmd2.with_argparser(store_data_parser)
|
||||
def do_store_data(self, opts):
|
||||
"""Perform the GlobalPlatform GET DATA command in order to store some card-specific data.
|
||||
See GlobalPlatform CardSpecification v2.3Section 11.11 for details."""
|
||||
"""Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
|
||||
See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
|
||||
response_permitted = opts.response == 'may_be_returned'
|
||||
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:
|
||||
"""Perform the GlobalPlatform GET DATA command in order to store some card-specific data.
|
||||
See GlobalPlatform CardSpecification v2.3Section 11.11 for details."""
|
||||
"""Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
|
||||
See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
|
||||
max_cmd_len = self._cmd.lchan.scc.max_cmd_len
|
||||
# Table 11-89 of GP Card Specification v2.3
|
||||
remainder = data
|
||||
@@ -585,7 +585,7 @@ class ADF_SD(CardADF):
|
||||
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(chunk) + "00")
|
||||
block_nr += 1
|
||||
response += data
|
||||
return data
|
||||
return h2b(response)
|
||||
|
||||
put_key_parser = argparse.ArgumentParser()
|
||||
put_key_parser.add_argument('--old-key-version-nr', type=auto_uint8, default=0, help='Old Key Version Number')
|
||||
@@ -627,7 +627,7 @@ class ADF_SD(CardADF):
|
||||
kcv_bin = compute_kcv(opts.key_type[i], h2b(opts.key_data[i])) or b''
|
||||
kcv = b2h(kcv_bin)
|
||||
if self._cmd.lchan.scc.scp:
|
||||
# encrypte key data with DEK of current 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)
|
||||
@@ -755,7 +755,7 @@ class ADF_SD(CardADF):
|
||||
|
||||
inst_load_parser = argparse.ArgumentParser()
|
||||
inst_load_parser.add_argument('--load-file-aid', type=is_hexstr, required=True,
|
||||
help='AID of the loded file')
|
||||
help='AID of the loaded file')
|
||||
inst_load_parser.add_argument('--security-domain-aid', type=is_hexstr, default='',
|
||||
help='AID of the Security Domain into which the file shalle be added')
|
||||
inst_load_parser.add_argument('--load-file-hash', type=is_hexstr, default='',
|
||||
@@ -845,7 +845,7 @@ class ADF_SD(CardADF):
|
||||
# TODO:tune chunk_len based on the overhead of the used SCP?
|
||||
# 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
|
||||
# transfer this in vaious chunks to the card
|
||||
# transfer this in various chunks to the card
|
||||
total_size = len(remainder)
|
||||
block_nr = 0
|
||||
while len(remainder):
|
||||
@@ -859,22 +859,28 @@ class ADF_SD(CardADF):
|
||||
_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))
|
||||
|
||||
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',
|
||||
help='JAVA-CARD CAP file to install')
|
||||
install_cap_parser_inst_prm_g = install_cap_parser.add_mutually_exclusive_group()
|
||||
install_cap_parser_inst_prm_g.add_argument('--install-parameters', type=is_hexstr, default=None,
|
||||
help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
||||
install_cap_parser_inst_prm_g_grp = install_cap_parser_inst_prm_g.add_argument_group()
|
||||
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-volatile-memory-quota',
|
||||
type=int, default=None,
|
||||
help='volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
||||
install_cap_parser_inst_prm_g_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_g_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)')
|
||||
# Ideally, the parser should enforce that:
|
||||
# * either the `--install-parameters` is given alone,
|
||||
# * or distinct `--install-parameters-*` are optionally given instead.
|
||||
# We tried to achieve this using mutually exclusive groups (add_mutually_exclusive_group).
|
||||
# However, group nesting was never supported, often failed to work correctly, and was unintentionally
|
||||
# exposed through inheritance. It has been deprecated since version 3.11, removed in version 3.14.
|
||||
# Hence, we have to implement the enforcement manually.
|
||||
install_cap_parser_inst_prm_grp = install_cap_parser.add_argument_group('Install Parameters')
|
||||
install_cap_parser_inst_prm_grp.add_argument('--install-parameters', type=is_hexstr, default=None,
|
||||
help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
|
||||
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-volatile-memory-quota',
|
||||
type=int, default=None,
|
||||
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)')
|
||||
|
||||
@cmd2.with_argparser(install_cap_parser)
|
||||
def do_install_cap(self, opts):
|
||||
@@ -888,9 +894,17 @@ class ADF_SD(CardADF):
|
||||
load_file_aid = cap.get_loadfile_aid()
|
||||
module_aid = cap.get_applet_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;
|
||||
else:
|
||||
# `--install-parameters-*` are all optional
|
||||
install_parameters = gen_install_parameters(opts.install_parameters_non_volatile_memory_quota,
|
||||
opts.install_parameters_volatile_memory_quota,
|
||||
opts.install_parameters_stk)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from osmocom.construct import *
|
||||
from osmocom.utils import *
|
||||
from osmocom.tlv import *
|
||||
@@ -46,7 +48,9 @@ class InstallParams(TLV_IE_Collection, nested=[AppSpecificParams, SystemSpecific
|
||||
# GPD_SPE_013, table 11-49
|
||||
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
|
||||
|
||||
@@ -54,19 +58,17 @@ def gen_install_parameters(non_volatile_memory_quota:int, volatile_memory_quota:
|
||||
install_params = InstallParams()
|
||||
install_params_dict = [{'app_specific_params': None}]
|
||||
|
||||
#Conditional
|
||||
if non_volatile_memory_quota and volatile_memory_quota and stk_parameter:
|
||||
system_specific_params = []
|
||||
#Optional
|
||||
if non_volatile_memory_quota:
|
||||
system_specific_params += [{'non_volatile_memory_quota': non_volatile_memory_quota}]
|
||||
#Optional
|
||||
if volatile_memory_quota:
|
||||
system_specific_params += [{'volatile_memory_quota': volatile_memory_quota}]
|
||||
#Optional
|
||||
if stk_parameter:
|
||||
system_specific_params += [{'stk_parameter': stk_parameter}]
|
||||
install_params_dict += [{'system_specific_params': system_specific_params}]
|
||||
# Collect system specific parameters (optional)
|
||||
system_specific_params = []
|
||||
if non_volatile_memory_quota is not None:
|
||||
system_specific_params.append({'non_volatile_memory_quota': non_volatile_memory_quota})
|
||||
if volatile_memory_quota is not None:
|
||||
system_specific_params.append({'volatile_memory_quota': volatile_memory_quota})
|
||||
if stk_parameter is not None:
|
||||
system_specific_params.append({'stk_parameter': stk_parameter})
|
||||
# Add system specific parameters to the install parameters, if any
|
||||
if system_specific_params:
|
||||
install_params_dict.append({'system_specific_params': system_specific_params})
|
||||
|
||||
install_params.from_dict(install_params_dict)
|
||||
return b2h(install_params.to_bytes())
|
||||
|
||||
@@ -266,11 +266,13 @@ class SCP02(SCP):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
def _compute_cryptograms(self, card_challenge: bytes, host_challenge: bytes):
|
||||
@@ -436,7 +438,7 @@ class Scp03SessionKeys:
|
||||
"""Obtain the ICV value computed as described in 6.2.6.
|
||||
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 R-DEC."""
|
||||
* is_response=True for computing the ICV for R-DEC."""
|
||||
if not is_response:
|
||||
self.block_nr += 1
|
||||
# The binary value of this number SHALL be left padded with zeroes to form a full block.
|
||||
|
||||
@@ -91,6 +91,7 @@ class UiccSdInstallParams(TLV_IE_Collection, nested=[UiccScp, AcceptExtradAppsAn
|
||||
|
||||
# Key Usage:
|
||||
# KVN 0x01 .. 0x0F reserved for SCP80
|
||||
# KVN 0x81 .. 0x8f reserved for SCP81
|
||||
# KVN 0x11 reserved for DAP specified in ETSI TS 102 226
|
||||
# KVN 0x20 .. 0x2F reserved for SCP02
|
||||
# KID 0x01 = ENC; 0x02 = MAC; 0x03 = DEK
|
||||
@@ -104,4 +105,4 @@ class UiccSdInstallParams(TLV_IE_Collection, nested=[UiccScp, AcceptExtradAppsAn
|
||||
# KID 0x02: SK.CASD.AUT (PK) and KS.CASD.AUT (Non-PK)
|
||||
# KID 0x03: SK.CASD.CT (P) and KS.CASD.CT (Non-PK)
|
||||
# KVN 0x75 KID 0x01: 16-byte DES key for Ciphered Load File Data Block
|
||||
# KVN 0xFF reserved for ISD with SCP02 without SCP80 s upport
|
||||
# KVN 0xFF reserved for ISD with SCP02 without SCP80 s support
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# JavaCard related utilities
|
||||
#
|
||||
# (C) 2024 by Sysmocom s.f.m.c. GmbH
|
||||
# (C) 2024 by sysmocom - s.f.m.c. GmbH
|
||||
# All Rights Reserved
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
@@ -97,7 +97,7 @@ class CapFile():
|
||||
raise ValueError("invalid cap file, %s missing!" % required_components[component])
|
||||
|
||||
def get_loadfile(self) -> bytes:
|
||||
"""Get the executeable loadfile as hexstring"""
|
||||
"""Get the executable loadfile as hexstring"""
|
||||
# Concatenate all cap file components in the specified order
|
||||
# see also: Java Card Platform Virtual Machine Specification, v3.2, section 6.3
|
||||
loadfile = self.__component['Header']
|
||||
|
||||
@@ -152,7 +152,8 @@ class SimCard(SimCardBase):
|
||||
return sw
|
||||
|
||||
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
|
||||
|
||||
def update_ad(self, mnc=None, opmode=None, ofm=None, path=EF['AD']):
|
||||
@@ -495,7 +496,7 @@ class IsimCard(UiccCardBase):
|
||||
|
||||
class MagicSimBase(abc.ABC, SimCard):
|
||||
"""
|
||||
Theses cards uses several record based EFs to store the provider infos,
|
||||
These cards uses several record based EFs to store the provider infos,
|
||||
each possible provider uses a specific record number in each EF. The
|
||||
indexes used are ( where N is the number of providers supported ) :
|
||||
- [2 .. N+1] for the operator name
|
||||
@@ -644,7 +645,7 @@ class MagicSim(MagicSimBase):
|
||||
|
||||
class FakeMagicSim(SimCard):
|
||||
"""
|
||||
Theses cards have a record based EF 3f00/000c that contains the provider
|
||||
These cards have a record based EF 3f00/000c that contains the provider
|
||||
information. See the program method for its format. The records go from
|
||||
1 to N.
|
||||
"""
|
||||
|
||||
@@ -296,7 +296,7 @@ def dec_addr_tlv(hexstr):
|
||||
|
||||
elif addr_type == 0x01: # IPv4
|
||||
# Skip address tye byte i.e. first byte in value list
|
||||
# Skip the unused byte in Octect 4 after address type byte as per 3GPP TS 31.102
|
||||
# Skip the unused byte in Octet 4 after address type byte as per 3GPP TS 31.102
|
||||
ipv4 = tlv[2][2:]
|
||||
content = '.'.join(str(x) for x in ipv4)
|
||||
return (content, '01')
|
||||
|
||||
138
pySim/log.py
Normal file
138
pySim/log.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" pySim: Logging
|
||||
"""
|
||||
|
||||
#
|
||||
# (C) 2025 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 logging
|
||||
from cmd2 import style
|
||||
|
||||
class _PySimLogHandler(logging.Handler):
|
||||
def __init__(self, log_callback):
|
||||
super().__init__()
|
||||
self.log_callback = log_callback
|
||||
|
||||
def emit(self, record):
|
||||
formatted_message = self.format(record)
|
||||
self.log_callback(formatted_message, record)
|
||||
|
||||
class PySimLogger:
|
||||
"""
|
||||
Static class to centralize the log output of PySim applications. This class can be used to print log messages from
|
||||
any pySim module. Configuration of the log behaviour (see setup and set_ methods) is entirely optional. In case no
|
||||
print callback is set (see setup method), the logger will pass the log messages directly to print() without applying
|
||||
any formatting to the original log message.
|
||||
"""
|
||||
|
||||
LOG_FMTSTR = "%(levelname)s: %(message)s"
|
||||
LOG_FMTSTR_VERBOSE = "%(module)s.%(lineno)d -- " + LOG_FMTSTR
|
||||
__formatter = logging.Formatter(LOG_FMTSTR)
|
||||
__formatter_verbose = logging.Formatter(LOG_FMTSTR_VERBOSE)
|
||||
|
||||
# No print callback by default, means that log messages are passed directly to print()
|
||||
print_callback = None
|
||||
|
||||
# No specific color scheme by default
|
||||
colors = {}
|
||||
|
||||
# The logging default is non-verbose logging on logging level DEBUG. This is a safe default that works for
|
||||
# applications that ignore the presence of the PySimLogger class.
|
||||
verbose = False
|
||||
logging.root.setLevel(logging.DEBUG)
|
||||
|
||||
def __init__(self):
|
||||
raise RuntimeError('static class, do not instantiate')
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
called, default settings apply.
|
||||
Args:
|
||||
print_callback : A callback function that accepts the resulting log string as input. The callback should
|
||||
have the following format: print_callback(message:str)
|
||||
colors : An optional dict through which certain log levels can be assigned a color.
|
||||
(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.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
|
||||
def set_verbose(verbose:bool = False):
|
||||
"""
|
||||
Enable/disable verbose logging. (has no effect in case no print callback is set, see method setup)
|
||||
Args:
|
||||
verbose: verbosity (True = verbose logging, False = normal logging)
|
||||
"""
|
||||
PySimLogger.verbose = verbose;
|
||||
|
||||
@staticmethod
|
||||
def set_level(level:int = logging.DEBUG):
|
||||
"""
|
||||
Set the logging level.
|
||||
Args:
|
||||
level: Logging level, valis log leves are: DEBUG, INFO, WARNING, ERROR and CRITICAL
|
||||
"""
|
||||
logging.root.setLevel(level)
|
||||
|
||||
@staticmethod
|
||||
def _log_callback(message, record):
|
||||
if not PySimLogger.print_callback:
|
||||
# In case no print callback has been set display the message as if it were printed trough a normal
|
||||
# python print statement.
|
||||
print(record.message)
|
||||
else:
|
||||
# When a print callback is set, use it to display the log line. Apply color if the API user chose one
|
||||
if PySimLogger.verbose:
|
||||
formatted_message = logging.Formatter.format(PySimLogger.__formatter_verbose, record)
|
||||
else:
|
||||
formatted_message = logging.Formatter.format(PySimLogger.__formatter, record)
|
||||
color = PySimLogger.colors.get(record.levelno)
|
||||
if color:
|
||||
if isinstance(color, str):
|
||||
PySimLogger.print_callback(color + formatted_message + "\033[0m")
|
||||
else:
|
||||
PySimLogger.print_callback(style(formatted_message, fg = color))
|
||||
else:
|
||||
PySimLogger.print_callback(formatted_message)
|
||||
|
||||
@staticmethod
|
||||
def get(log_facility: str):
|
||||
"""
|
||||
Set up and return a new python logger object
|
||||
Args:
|
||||
log_facility : Name of log facility (e.g. "MAIN", "RUNTIME"...)
|
||||
"""
|
||||
logger = logging.getLogger(log_facility)
|
||||
handler = _PySimLogHandler(log_callback=PySimLogger._log_callback)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
27
pySim/ota.py
27
pySim/ota.py
@@ -57,12 +57,13 @@ CompactRemoteResp = Struct('number_of_commands'/Int8ub,
|
||||
'last_response_data'/HexAdapter(GreedyBytes))
|
||||
|
||||
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
|
||||
SPI = BitStruct( # first octet
|
||||
Padding(3),
|
||||
'counter'/Enum(BitsInteger(2), no_counter=0, counter_no_replay_or_seq=1,
|
||||
counter_must_be_higher=2, counter_must_be_lower=3),
|
||||
'counter'/CNTR_REQ,
|
||||
'ciphering'/Flag,
|
||||
'rc_cc_ds'/RC_CC_DS,
|
||||
# second octet
|
||||
@@ -70,8 +71,7 @@ SPI = BitStruct( # first octet
|
||||
'por_in_submit'/Flag,
|
||||
'por_shall_be_ciphered'/Flag,
|
||||
'por_rc_cc_ds'/RC_CC_DS,
|
||||
'por'/Enum(BitsInteger(2), no_por=0,
|
||||
por_required=1, por_only_when_error=2)
|
||||
'por'/POR_REQ
|
||||
)
|
||||
|
||||
# TS 102 225 Section 5.1.2
|
||||
@@ -221,12 +221,12 @@ class OtaAlgoCrypt(OtaAlgo, abc.ABC):
|
||||
for subc in cls.__subclasses__():
|
||||
if subc.enum_name == otak.algo_crypt:
|
||||
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):
|
||||
def __init__(self, otak: OtaKeyset):
|
||||
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)
|
||||
|
||||
def sign(self, data:bytes) -> bytes:
|
||||
@@ -302,7 +302,7 @@ class OtaAlgoAuthDES3(OtaAlgoAuth):
|
||||
class OtaAlgoCryptAES(OtaAlgoCrypt):
|
||||
name = 'AES'
|
||||
enum_name = 'aes_cbc'
|
||||
blocksize = 16
|
||||
blocksize = 16 # TODO: is this needed?
|
||||
def _encrypt(self, data:bytes) -> bytes:
|
||||
cipher = AES.new(self.otak.kic, AES.MODE_CBC, self.iv)
|
||||
return cipher.encrypt(data)
|
||||
@@ -356,20 +356,20 @@ class OtaDialectSms(OtaDialect):
|
||||
|
||||
# CHL + SPI (+ KIC + KID)
|
||||
part_head = self.hdr_construct.build({'chl': chl, 'spi':spi, 'kic':kic, 'kid':kid, 'tar':tar})
|
||||
print("part_head: %s" % b2h(part_head))
|
||||
#print("part_head: %s" % b2h(part_head))
|
||||
|
||||
# CNTR + PCNTR (CNTR not used)
|
||||
part_cnt = otak.cntr.to_bytes(5, 'big') + pad_cnt.to_bytes(1, 'big')
|
||||
print("part_cnt: %s" % b2h(part_cnt))
|
||||
#print("part_cnt: %s" % b2h(part_cnt))
|
||||
|
||||
envelope_data = part_head + part_cnt + apdu
|
||||
print("envelope_data: %s" % b2h(envelope_data))
|
||||
#print("envelope_data: %s" % b2h(envelope_data))
|
||||
|
||||
# 2-byte CPL. CPL is part of RC/CC/CPI to end of secured data, including any padding for ciphering
|
||||
# CPL from and including CPI to end of secured data, including any padding for ciphering
|
||||
cpl = len(envelope_data) + len_sig
|
||||
envelope_data = cpl.to_bytes(2, 'big') + envelope_data
|
||||
print("envelope_data with cpl: %s" % b2h(envelope_data))
|
||||
#print("envelope_data with cpl: %s" % b2h(envelope_data))
|
||||
|
||||
if spi['rc_cc_ds'] == 'cc':
|
||||
cc = otak.auth.sign(envelope_data)
|
||||
@@ -383,7 +383,7 @@ class OtaDialectSms(OtaDialect):
|
||||
else:
|
||||
raise ValueError("Invalid rc_cc_ds: %s" % spi['rc_cc_ds'])
|
||||
|
||||
print("envelope_data with sig: %s" % b2h(envelope_data))
|
||||
#print("envelope_data with sig: %s" % b2h(envelope_data))
|
||||
|
||||
# encrypt as needed
|
||||
if spi['ciphering']: # ciphering is requested
|
||||
@@ -395,7 +395,7 @@ class OtaDialectSms(OtaDialect):
|
||||
else:
|
||||
envelope_data = part_head + envelope_data
|
||||
|
||||
print("envelope_data: %s" % b2h(envelope_data))
|
||||
#print("envelope_data: %s" % b2h(envelope_data))
|
||||
|
||||
if len(envelope_data) > 140:
|
||||
raise ValueError('Cannot encode command in a single SMS; Fragmentation not implemented')
|
||||
@@ -492,7 +492,6 @@ class OtaDialectSms(OtaDialect):
|
||||
else:
|
||||
raise OtaCheckError('Unknown por_rc_cc_ds: %s' % spi['por_rc_cc_ds'])
|
||||
|
||||
print(res)
|
||||
# TODO: ExpandedRemoteResponse according to TS 102 226 5.2.2
|
||||
if res.response_status == 'por_ok' and len(res['secured_data']):
|
||||
dec = CompactRemoteResp.parse(res['secured_data'])
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
@@ -110,7 +110,7 @@ class CardProfile:
|
||||
@abc.abstractmethod
|
||||
def _try_match_card(cls, scc: SimCardCommands) -> None:
|
||||
"""Try to see if the specific profile matches the card. This method is a
|
||||
placeholder that is overloaded by specific dirived classes. The method
|
||||
placeholder that is overloaded by specific derived classes. The method
|
||||
actively probes the card to make sure the profile class matches the
|
||||
physical card. This usually also means that the card is reset during
|
||||
the process, so this method must not be called at random times. It may
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# coding=utf-8
|
||||
|
||||
"""Representation of the runtime state of an application like pySim-shell.
|
||||
"""
|
||||
|
||||
@@ -23,6 +24,9 @@ from osmocom.tlv import bertlv_parse_one
|
||||
|
||||
from pySim.exceptions import *
|
||||
from pySim.filesystem import *
|
||||
from pySim.log import PySimLogger
|
||||
|
||||
log = PySimLogger.get(__name__)
|
||||
|
||||
def lchan_nr_from_cla(cla: int) -> int:
|
||||
"""Resolve the logical channel number from the CLA byte."""
|
||||
@@ -44,6 +48,7 @@ class RuntimeState:
|
||||
card : pysim.cards.Card instance
|
||||
profile : CardProfile instance
|
||||
"""
|
||||
|
||||
self.mf = CardMF(profile=profile)
|
||||
self.card = card
|
||||
self.profile = profile
|
||||
@@ -60,10 +65,13 @@ class RuntimeState:
|
||||
self.card.set_apdu_parameter(
|
||||
cla=self.profile.cla, sel_ctrl=self.profile.sel_ctrl)
|
||||
|
||||
# make sure MF is selected before probing for Addons
|
||||
self.lchan[0].select('MF')
|
||||
|
||||
for addon_cls in self.profile.addons:
|
||||
addon = addon_cls()
|
||||
if addon.probe(self.card):
|
||||
print("Detected %s Add-on \"%s\"" % (self.profile, addon))
|
||||
log.info("Detected %s Add-on \"%s\"" % (self.profile, addon))
|
||||
for f in addon.files_in_mf:
|
||||
self.mf.add_file(f)
|
||||
|
||||
@@ -97,18 +105,18 @@ class RuntimeState:
|
||||
apps_taken = []
|
||||
if aids_card:
|
||||
aids_taken = []
|
||||
print("AIDs on card:")
|
||||
log.info("AIDs on card:")
|
||||
for a in aids_card:
|
||||
for f in apps_profile:
|
||||
if f.aid in a:
|
||||
print(" %s: %s (EF.DIR)" % (f.name, a))
|
||||
log.info(" %s: %s (EF.DIR)" % (f.name, a))
|
||||
aids_taken.append(a)
|
||||
apps_taken.append(f)
|
||||
aids_unknown = set(aids_card) - set(aids_taken)
|
||||
for a in aids_unknown:
|
||||
print(" unknown: %s (EF.DIR)" % a)
|
||||
log.info(" unknown: %s (EF.DIR)" % a)
|
||||
else:
|
||||
print("warning: 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
|
||||
# probe for those applications
|
||||
@@ -123,7 +131,7 @@ class RuntimeState:
|
||||
_data, sw = self.card.select_adf_by_aid(f.aid)
|
||||
self.selected_adf = f
|
||||
if sw == "9000":
|
||||
print(" %s: %s" % (f.name, f.aid))
|
||||
log.info(" %s: %s" % (f.name, f.aid))
|
||||
apps_taken.append(f)
|
||||
except (SwMatchError, ProtocolError):
|
||||
pass
|
||||
@@ -147,7 +155,7 @@ class RuntimeState:
|
||||
# select MF to reset internal state and to verify card really works
|
||||
self.lchan[0].select('MF', cmd_app)
|
||||
self.lchan[0].selected_adf = None
|
||||
# store ATR as part of our card identies dict
|
||||
# store ATR as part of our card identities dict
|
||||
self.identity['ATR'] = atr
|
||||
return atr
|
||||
|
||||
@@ -321,7 +329,7 @@ class RuntimeLchan:
|
||||
# If we succeed, we know that the file exists on the card and we may
|
||||
# proceed with creating a new CardEF object in the local file model at
|
||||
# run time. In case the file does not exist on the card, we just abort.
|
||||
# The state on the card (selected file/application) wont't be changed,
|
||||
# The state on the card (selected file/application) won't be changed,
|
||||
# so we do not have to update any state in that case.
|
||||
(data, _sw) = self.scc.select_file(fid)
|
||||
except SwMatchError as swm:
|
||||
@@ -470,11 +478,15 @@ class RuntimeLchan:
|
||||
|
||||
def get_file_for_filename(self, name: str):
|
||||
"""Get the related CardFile object for a specified filename."""
|
||||
if is_hex(name):
|
||||
name = name.lower()
|
||||
sels = self.selected_file.get_selectables()
|
||||
return sels[name]
|
||||
|
||||
def activate_file(self, name: str):
|
||||
"""Request ACTIVATE FILE of specified file."""
|
||||
if is_hex(name):
|
||||
name = name.lower()
|
||||
sels = self.selected_file.get_selectables()
|
||||
f = sels[name]
|
||||
data, sw = self.scc.activate_file(f.fid)
|
||||
@@ -507,6 +519,47 @@ class RuntimeLchan:
|
||||
dec_data = self.selected_file.decode_hex(data)
|
||||
return (dec_data, sw)
|
||||
|
||||
def __get_writeable_size(self):
|
||||
""" Determine the writable size (file or record) using the cached FCP parameters of the currently selected
|
||||
file. Return None in case the writeable size cannot be determined (no FCP available, FCP lacks size
|
||||
information).
|
||||
"""
|
||||
fcp = self.selected_file_fcp
|
||||
if not fcp:
|
||||
return None
|
||||
|
||||
structure = fcp.get('file_descriptor', {}).get('file_descriptor_byte', {}).get('structure')
|
||||
if not structure:
|
||||
return None
|
||||
|
||||
if structure == 'transparent':
|
||||
return fcp.get('file_size')
|
||||
elif structure == 'linear_fixed':
|
||||
return fcp.get('file_descriptor', {}).get('record_len')
|
||||
else:
|
||||
return None
|
||||
|
||||
def __check_writeable_size(self, data_len):
|
||||
""" Guard against unsuccessful writes caused by attempts to write data that exceeds the file limits. """
|
||||
|
||||
writeable_size = self.__get_writeable_size()
|
||||
if not writeable_size:
|
||||
return
|
||||
|
||||
if isinstance(self.selected_file, TransparentEF):
|
||||
writeable_name = "file"
|
||||
elif isinstance(self.selected_file, LinFixedEF):
|
||||
writeable_name = "record"
|
||||
else:
|
||||
writeable_name = "object"
|
||||
|
||||
if data_len > writeable_size:
|
||||
raise TypeError("Data length (%u) exceeds %s size (%u) by %u bytes" %
|
||||
(data_len, writeable_name, writeable_size, data_len - writeable_size))
|
||||
elif data_len < writeable_size:
|
||||
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))
|
||||
|
||||
def update_binary(self, data_hex: str, offset: int = 0):
|
||||
"""Update transparent EF binary data.
|
||||
|
||||
@@ -517,6 +570,7 @@ class RuntimeLchan:
|
||||
if not isinstance(self.selected_file, TransparentEF):
|
||||
raise TypeError("Only works with TransparentEF, but %s is %s" % (self.selected_file,
|
||||
self.selected_file.__class__.__mro__))
|
||||
self.__check_writeable_size(len(data_hex) // 2 + offset)
|
||||
return self.scc.update_binary(self.selected_file.fid, data_hex, offset, conserve=self.rs.conserve_write)
|
||||
|
||||
def update_binary_dec(self, data: dict):
|
||||
@@ -564,6 +618,7 @@ class RuntimeLchan:
|
||||
if not isinstance(self.selected_file, LinFixedEF):
|
||||
raise TypeError("Only works with Linear Fixed EF, but %s is %s" % (self.selected_file,
|
||||
self.selected_file.__class__.__mro__))
|
||||
self.__check_writeable_size(len(data_hex) // 2)
|
||||
return self.scc.update_record(self.selected_file.fid, rec_nr, data_hex,
|
||||
conserve=self.rs.conserve_write,
|
||||
leftpad=self.selected_file.leftpad)
|
||||
|
||||
@@ -32,7 +32,7 @@ class SecureChannel(abc.ABC):
|
||||
pass
|
||||
|
||||
def send_apdu_wrapper(self, send_fn: callable, pdu: Hexstr, *args, **kwargs) -> ResTuple:
|
||||
"""Wrapper function to wrap command APDU and unwrap repsonse APDU around send_apdu callable."""
|
||||
"""Wrapper function to wrap command APDU and unwrap response APDU around send_apdu callable."""
|
||||
pdu_wrapped = b2h(self.wrap_cmd_apdu(h2b(pdu)))
|
||||
res, sw = send_fn(pdu_wrapped, *args, **kwargs)
|
||||
res_unwrapped = b2h(self.unwrap_rsp_apdu(h2b(sw), h2b(res)))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user