Jenkins Builder has posted comments on this change by Hoernchen. ( https://gerrit.osmocom.org/c/pysim/+/43546?usp=email )
Change subject: ota: indefinite length en/decoding support
......................................................................
Patch Set 1:
(1 comment)
File pySim/ota.py:
Robot Comment from checkpatch (run ID ):
https://gerrit.osmocom.org/c/pysim/+/43546/comment/ad498a04_55bfccff?usp=em… :
PS1, Line 195: """Returns the content octets of an indef lengh template like
'lengh' may be misspelled - perhaps 'length'?
--
To view, visit https://gerrit.osmocom.org/c/pysim/+/43546?usp=email
To unsubscribe, or for help writing mail filters, visit https://gerrit.osmocom.org/settings?usp=email
Gerrit-MessageType: comment
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: I4e023112e98729489ed443eec3ed5ab45c773b17
Gerrit-Change-Number: 43546
Gerrit-PatchSet: 1
Gerrit-Owner: Hoernchen <ewild(a)sysmocom.de>
Gerrit-CC: Jenkins Builder
Gerrit-Comment-Date: Tue, 08 Sep 2026 08:41:49 +0000
Gerrit-HasComments: Yes
Gerrit-Has-Labels: No
Hoernchen has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/43549?usp=email )
Change subject: osmo-smdpp: add --smdp-address to set ES9+ SM-DP+ address
......................................................................
osmo-smdpp: add --smdp-address to set ES9+ SM-DP+ address
The ES9+ SM-DP+ address is used for the smdpAddress check and
for serverSigned1.serverAddress, both default to HOSTNAME.
Add --smdp-address (default: HOSTNAME) so the advertised address can carry a
port and still match what the LPA connects to when the TLS endpoint is bound
to other ports than 443. TLS certificate identity is unaffected, this is
only the ES9+ address.
SGP.22 defines both smdpAddress and serverSigned1.serverAddress as an FQDN,
so this is useful for debugging, but likely not spec compliant.
Change-Id: I2fa822c7d5e0d5b68a6704a1779f9923505f9008
---
M osmo-smdpp.py
1 file changed, 6 insertions(+), 1 deletion(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/49/43549/1
diff --git a/osmo-smdpp.py b/osmo-smdpp.py
index 8120b0f..cf26e74 100755
--- a/osmo-smdpp.py
+++ b/osmo-smdpp.py
@@ -916,12 +916,17 @@
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)
+ parser.add_argument("--smdp-address", default=HOSTNAME,
+ help="ES9+ SM-DP+ address advertised, defaults to HOSTNAME (%(default)s)." \
+ "Set this to include the TLS port (e.g. testsmdpplus1.example.com:8443)" \
+ "when binding a non-443 port, so it matches the address the LPA connects to." \
+ "The TLS cert identity is unaffected. Useful for debugging.")
args = parser.parse_args()
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)
+ hs = SmDppHttpServer(server_hostname=args.smdp_address, ci_certs_path=os.path.join(common_cert_path, 'CertificateIssuer'), common_cert_path=common_cert_path, use_brainpool=args.brainpool)
if(args.nossl):
hs.app.run(args.host, args.port)
else:
--
To view, visit https://gerrit.osmocom.org/c/pysim/+/43549?usp=email
To unsubscribe, or for help writing mail filters, visit https://gerrit.osmocom.org/settings?usp=email
Gerrit-MessageType: newchange
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: I2fa822c7d5e0d5b68a6704a1779f9923505f9008
Gerrit-Change-Number: 43549
Gerrit-PatchSet: 1
Gerrit-Owner: Hoernchen <ewild(a)sysmocom.de>
Hoernchen has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/43550?usp=email )
Change subject: osmo-smdpp: bind TLS endpoint to -H interface
......................................................................
osmo-smdpp: bind TLS endpoint to -H interface
klein ignores host/port when endpoint_description is set, so -H has
no effect on the TLS listener, it binds every interface.
Append interface= to the string so -H means the same thing with and
without TLS.
Change-Id: I737c2e7cfa1ca7b825bd36a4a489760918031b8b
---
M osmo-smdpp.py
1 file changed, 1 insertion(+), 1 deletion(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/50/43550/1
diff --git a/osmo-smdpp.py b/osmo-smdpp.py
index cf26e74..ef214a9 100755
--- a/osmo-smdpp.py
+++ b/osmo-smdpp.py
@@ -955,7 +955,7 @@
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}'
+ SERVER_STRING = f'ssl:{args.port}:privateKey={cert_skpath}:certKey={cert_pempath}:dhParameters={dhparam_path}:interface={args.host}'
print(SERVER_STRING)
hs.app.run(host=HOSTNAME, port=args.port, endpoint_description=SERVER_STRING)
--
To view, visit https://gerrit.osmocom.org/c/pysim/+/43550?usp=email
To unsubscribe, or for help writing mail filters, visit https://gerrit.osmocom.org/settings?usp=email
Gerrit-MessageType: newchange
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: I737c2e7cfa1ca7b825bd36a4a489760918031b8b
Gerrit-Change-Number: 43550
Gerrit-PatchSet: 1
Gerrit-Owner: Hoernchen <ewild(a)sysmocom.de>
Hoernchen has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/43552?usp=email )
Change subject: GP: only send a GET STATUS tag list to cards that support it
......................................................................
GP: only send a GET STATUS tag list to cards that support it
get_status() has appended a hardcoded '5c054f9f70c5cc' to the command data
field. Of the data objects in GP CS v2.3.1 Table 11-35 only the AID
search tag 4F is mandatory, the tag list is optional and not supported
in v2.1.1, where Section 9.4.2.3 defines the data field as the search
qualifier. Cards implementing that revision can reject anything else
with 6A80 as per v2.1.1 Table 9-26.
A sysmocom SJA5 does that. Its data field must be one 4F
TLV, the value is free, but nothing may precede or follow it.
So every subset returned nothing at all...
There is no need to guess: v2.1.1/v2.3.1 Section 7.4.1.3 Card Recognition
Data is "shall be present" and contains the GP version on selected SD.
Query it once, and send the tag list to cards that announce v2.2 or later.
SJA5 reports 2.1.1, sysmoEUICC reports 2.2.
Two more problems with the old list:
- A tag list is an inclusion list, old list omits tag 84, so it
suppressed the Executable Module AIDs
- It asks for tag C5 for Executable Load Files, which "may" be answered
with an error status.
Fix this by constricting or omitting the tag list depending on reported
GP version.
Change-Id: I74cd2bd47617d616bede6453397f544cde5abcb7
---
M pySim/global_platform/__init__.py
M tests/unittests/test_globalplatform.py
2 files changed, 205 insertions(+), 11 deletions(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/52/43552/1
diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py
index 80772fe..4a41d2f 100644
--- a/pySim/global_platform/__init__.py
+++ b/pySim/global_platform/__init__.py
@@ -37,6 +37,9 @@
from pySim.profile import CardProfile
from pySim.ota import SimFileAccessAndToolkitAppSpecParams
from pySim.javacard import CapFile
+from pySim.log import PySimLogger
+
+log = PySimLogger.get(__name__)
# GPCS Table 11-48 Load Parameter Tags
class NonVolatileCodeMinMemoryReq(BER_TLV_IE, tag=0xC6):
@@ -532,6 +535,63 @@
ExecutableModuleAID, AssociatedSecurityDomainAID]):
pass
+# GP CS v2.3.1 Table 11-36/11-37 possible data objects requested/returned from GET STATUS for each registry entry.
+# Applications and Executable Load Files have _different_ sets, so a tag list requesting them has
+# to match the subset because 11.4.2.3 warns that asking for a data object an entry does not have
+# "may" be answered with an error status.
+GetStatusTagListIEs = {
+ # Table 11-36 GP Application Data
+ 'isd': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter,
+ ExecutableLoadFileAID, AssociatedSecurityDomainAID],
+ 'applications': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter,
+ ExecutableLoadFileAID, AssociatedSecurityDomainAID],
+ # Table 11-37 GP Executable Load File Data. 84 only for the subset that asks for the modules (Note 2)!
+ 'files': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber,
+ AssociatedSecurityDomainAID],
+ 'files_and_modules': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber,
+ ExecutableModuleAID, AssociatedSecurityDomainAID],
+}
+
+def get_status_tag_list(subset: str) -> bytes:
+ """Encode the GET STATUS tag list for the given status subset"""
+ tags = b''.join([bertlv_encode_tag(ie.tag) for ie in GetStatusTagListIEs[subset]])
+ return b'\x5c' + bertlv_encode_len(len(tags)) + tags
+
+# GP CS v2.3.1 Appendix H.2 / Table H-1
+# oid prefix {iso(1) member-body(2) country-USA(840) globalPlatform(114283)} + card management type 2
+# afterwards GP version.
+OID_GP_CARD_MGMT_TYPE = h2b('2a864886fc6b02')
+
+def _find_tlv_value(decoded, key: str):
+ """depth first search for the nested decoded TLV_IE dict/list"""
+ if isinstance(decoded, dict):
+ for k, v in decoded.items():
+ if k == key:
+ return v
+ found = _find_tlv_value(v, key)
+ if found is not None:
+ return found
+ elif isinstance(decoded, list):
+ for item in decoded:
+ found = _find_tlv_value(item, key)
+ if found is not None:
+ return found
+ return None
+
+def decode_gp_version(card_data: bytes) -> Optional[Tuple[int, ...]]:
+ """GP version from Card Data returned by GET DATA, like (2, 1, 1) or (2, 2).
+ None if cm type OID is absent/unknown"""
+ cd = CardData()
+ cd.from_tlv(card_data)
+ ctv = _find_tlv_value(cd.to_dict(), 'card_management_type_and_version')
+ oid = _find_tlv_value(ctv, 'object_identifier') if ctv is not None else None
+ if oid is None:
+ return None
+ oid = h2b(oid) if isinstance(oid, str) else bytes(oid)
+ if not oid.startswith(OID_GP_CARD_MGMT_TYPE):
+ return None
+ return tuple(oid[len(OID_GP_CARD_MGMT_TYPE):])
+
# Application Dedicated File of a Security Domain
class ADF_SD(CardADF):
StoreData = BitStruct('last_block'/Flag,
@@ -733,19 +793,62 @@
for grd in grd_list:
self._cmd.poutput_json(grd.to_dict())
+ def gp_version(self) -> Optional[Tuple[int, ...]]:
+ """GP version the selected SD reports in its Card Recognition
+ Data, e.g. (2, 1, 1). Card Recognition Data "shall be present" v2.1.1/v2.3.1 section 7.4.1.3,
+ so this must succeed no matter the GP version. None if card did not answer GET DATA / OID unknown.
+ Cached, it cannot change during a session."""
+ if not hasattr(self, '_gp_version'):
+ self._gp_version = None
+ try:
+ data, _sw = self._cmd.lchan.scc.get_data(cla=0x80, tag=CardData.tag)
+ self._gp_version = decode_gp_version(h2b(data))
+ except (SwMatchError, ValueError) as e:
+ log.warning("Could not determine GlobalPlatform version: %s", e)
+ return self._gp_version
+
def get_status(self, subset:str, aid_search_qualifier:Hexstr = '') -> List[GpRegistryRelatedData]:
- subset_hex = b2h(build_construct(StatusSubset, subset))
aid = ApplicationAID(decoded=aid_search_qualifier)
- cmd_data = aid.to_tlv() + h2b('5c054f9f70c5cc')
+ # GPC CardSpec v2.3.1 Table 11-35 says only the AID search tag is mandatory, tag list is
+ # Optional and not present in the older v2.1.1, where section 9.4.2.3 defines the data
+ # field as the search qualifier.
+ # Cards like the sja5 implementing that old GP version reject anything else with 6A80
+ # from v2.1.1 Table 9-26 so only send a tag list to a card that announces v2.2 or later.
+ #
+ # Not sending one is not a problem on older cards, the tag list only gives us data beyond
+ # what 11.4.3.1 gives us anyway, for example the associated SD AID which matters on an eUICC
+ # where entries belong to different SD.
+ version = self.gp_version()
+ if version is not None and version >= (2, 2):
+ try:
+ return self._get_status(subset, aid.to_tlv() + get_status_tag_list(subset))
+ except SwMatchError as e:
+ # Retry if v2.2 or later but rejected the tag list anyway.
+ # 6A80 and 6A88 are the error conditions GET STATUS defines in table 11-39.
+ # Retrying beats not ending up with a list again...
+ if e.sw_actual not in ('6a80', '6a88'):
+ raise
+ log.warning("Card reports GlobalPlatform %s but answered %s to the GET STATUS tag list; "
+ "retrying with the default search",
+ '.'.join(str(v) for v in version), e.sw_actual)
+ return self._get_status(subset, aid.to_tlv(), empty_on_6a88=True)
+
+ def _get_status(self, subset:str, cmd_data:bytes,
+ empty_on_6a88: bool = False) -> List[GpRegistryRelatedData]:
+ subset_hex = b2h(build_construct(StatusSubset, subset))
p2 = 0x02 # TLV format according to Table 11-36
grd_list = []
while True:
hdr = "80F2%s%02x%02x" % (subset_hex, p2, len(cmd_data))
data, sw = self._cmd.lchan.scc.send_apdu(hdr + b2h(cmd_data) + "00")
if sw == '6a88':
- # "Referenced data not found": nothing (more) matches the requested subset and AID
- # search qualifier. That is empty, not error?
- return grd_list
+ # Table 11-39 "Referenced data not found". After collecting all pages this can
+ # only mean "nothing more matches" -> listing is complete. On the first page
+ # it is ambiguous, empty result or bad command data field, so leave that to get_status()
+ # which knows if a tag list was sent.
+ if grd_list or empty_on_6a88:
+ return grd_list
+ raise SwMatchError(sw, '9000/6310')
if sw not in ['9000', '6310']:
# Never return a silently truncated registry
raise SwMatchError(sw, '9000/6310')
diff --git a/tests/unittests/test_globalplatform.py b/tests/unittests/test_globalplatform.py
index 2dbec70..1116023 100644
--- a/tests/unittests/test_globalplatform.py
+++ b/tests/unittests/test_globalplatform.py
@@ -712,14 +712,26 @@
for wrapped in scc.wrapped:
self.assertLessEqual(wrapped[4], 255)
+# Real Card Data (GET DATA '66'), as returned by sja5 + euicc
+CARD_DATA_V211 = ('6631732f06072a864886fc6b01600c060a2a864886fc6b0202010163090607'
+ '2a864886fc6b03640b06092a864886fc6b040215')
+CARD_DATA_V22 = ('663b733906072a864886fc6b01600b06092a864886fc6b020202630906072a86'
+ '4886fc6b03640b06092a864886fc6b040370640b06092a864886fc6b04810400')
+
class _FakeScc:
"""mock lchan.scc: replays scripted (data, sw) pairs + records the APDUs sent."""
- def __init__(self, responses):
+ def __init__(self, responses, card_data=CARD_DATA_V211):
self._responses = list(responses)
+ self._card_data = card_data
self.sent = []
+ def get_data(self, cla, tag):
+ if self._card_data is None:
+ raise SwMatchError('6a88', '9000')
+ return self._card_data, '9000'
+
def send_apdu(self, apdu):
self.sent.append(apdu.lower())
if not self._responses:
@@ -727,6 +739,28 @@
return self._responses.pop(0)
+class GpVersion_Test(unittest.TestCase):
+ """GP version from Card Recognition Data, which v2.1.1/v2.3.1 section 7.4.1.3
+ require to be present. The OID under tag 60 is {globalPlatform 2 v...}."""
+
+ def test_decode_real_cards(self):
+ self.assertEqual(decode_gp_version(h2b(CARD_DATA_V211)), (2, 1, 1))
+ self.assertEqual(decode_gp_version(h2b(CARD_DATA_V22)), (2, 2))
+
+ def test_unknown_oid_is_none(self):
+ self.assertIsNone(decode_gp_version(h2b('66097307060512345678')))
+
+ def test_tag_lists_follow_the_spec_tables(self):
+ """table 11-36 applications, table 11-37 for load files"""
+ self.assertEqual(b2h(get_status_tag_list('isd')), '5c074f9f70c5cfc4cc')
+ self.assertEqual(b2h(get_status_tag_list('applications')), '5c074f9f70c5cfc4cc')
+ self.assertEqual(b2h(get_status_tag_list('files')), '5c054f9f70cecc')
+ self.assertEqual(b2h(get_status_tag_list('files_and_modules')), '5c064f9f70ce84cc')
+ # C5 never load files, 84 never applications
+ self.assertNotIn('c5', b2h(get_status_tag_list('files')))
+ self.assertNotIn('84', b2h(get_status_tag_list('applications'))[4:])
+
+
class GetStatus_Pagination_Test(unittest.TestCase):
"""GP CS v2.3 section 11.4.3.1 GET STATUS pagination test
@@ -737,8 +771,8 @@
ENTRY_1 = 'e3074f05a000000151'
ENTRY_2 = 'e3074f05a000000152'
- def _sd(self, responses):
- scc = _FakeScc(responses)
+ def _sd(self, responses, card_data=CARD_DATA_V211):
+ scc = _FakeScc(responses, card_data)
cmd = type('_Cmd', (), {'lchan': type('_Lchan', (), {'scc': scc})()})()
# cmd2 strikes again, CommandSet exposes _cmd as a read only property, needs shadowing
_SD = type('_SD', (ADF_SD.AddlShellCommands,), {'_cmd': cmd})
@@ -750,15 +784,15 @@
def test_single_page(self):
sd, scc = self._sd([(self.ENTRY_1, '9000')])
grd_list = sd.get_status('applications')
- self.assertEqual(scc.sent, ['80f24002094f005c054f9f70c5cc00'])
+ self.assertEqual(scc.sent, ['80f24002024f0000'])
self.assertEqual(self._aids(grd_list), ['a000000151'])
def test_two_pages(self):
"""6310 -> reissue with P2 bit 1 set -> 9000, both pages in result"""
sd, scc = self._sd([(self.ENTRY_1, '6310'), (self.ENTRY_2, '9000')])
grd_list = sd.get_status('applications')
- self.assertEqual(scc.sent, ['80f24002094f005c054f9f70c5cc00',
- '80f24003094f005c054f9f70c5cc00'])
+ self.assertEqual(scc.sent, ['80f24002024f0000',
+ '80f24003024f0000'])
self.assertEqual(self._aids(grd_list), ['a000000151', 'a000000152'])
def test_three_pages_keep_p2_next_occurrence(self):
@@ -772,6 +806,44 @@
sd, _scc = self._sd([('', '6a88')])
self.assertEqual(sd.get_status('applications'), [])
+ def test_v211_card_gets_no_tag_list(self):
+ """v2.1.1 section 9.4.2.3 has no tag list,not send a tag list"""
+ sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=CARD_DATA_V211)
+ sd.get_status('applications')
+ self.assertEqual(scc.sent, ['80f24002024f0000'])
+ self.assertNotIn('5c', scc.sent[0][8:])
+
+ def test_v22_card_gets_a_tag_list(self):
+ sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=CARD_DATA_V22)
+ sd.get_status('applications')
+ self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00'])
+
+ def test_unknown_version_gets_no_tag_list(self):
+ """If the card will not say, assume the conservative form that works everywhere."""
+ sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=None)
+ sd.get_status('applications')
+ self.assertEqual(scc.sent, ['80f24002024f0000'])
+
+ def test_v22_card_rejecting_tag_list_falls_back(self):
+ """card announcing v2.2+ that still answers 6A80 to the tag list."""
+ sd, scc = self._sd([('', '6a80'), (self.ENTRY_1, '9000')], card_data=CARD_DATA_V22)
+ grd_list = sd.get_status('applications')
+ self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00',
+ '80f24002024f0000'])
+ self.assertEqual(self._aids(grd_list), ['a000000151'])
+
+ def test_aid_search_qualifier(self):
+ sd, scc = self._sd([(self.ENTRY_1, '9000')])
+ sd.get_status('applications', 'a000000087')
+ self.assertEqual(scc.sent, ['80f24002074f05a00000008700'])
+
+ def test_6a80_is_reported_on_a_v211_card(self):
+ """no tag list -> 6A80 is error"""
+ sd, _scc = self._sd([('', '6a80')], card_data=CARD_DATA_V211)
+ with self.assertRaises(SwMatchError) as ctx:
+ sd.get_status('applications')
+ self.assertEqual(ctx.exception.sw_actual, '6a80')
+
def test_unexpected_sw_is_not_silently_truncated(self):
"""partial is not complete result"""
sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6982')])
@@ -779,7 +851,26 @@
sd.get_status('applications')
self.assertEqual(ctx.exception.sw_actual, '6982')
+ def test_v22_card_answering_6a88_to_the_tag_list_falls_back(self):
+ """6A88 is the other GET STATUS error condition of table 11-39, section 11.4.2.3
+ says we may get get an error status. 6A88 to the tag-list attempt should be retried
+ without it or we get nothing"""
+ sd, scc = self._sd([('', '6a88'), (self.ENTRY_1, '9000')], card_data=CARD_DATA_V22)
+ grd_list = sd.get_status('applications')
+ self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00',
+ '80f24002024f0000'])
+ self.assertEqual(self._aids(grd_list), ['a000000151'])
+ def test_v22_card_with_a_genuinely_empty_subset(self):
+ """...and when the retry answers 6A88, the list really is empty."""
+ sd, scc = self._sd([('', '6a88'), ('', '6a88')], card_data=CARD_DATA_V22)
+ self.assertEqual(sd.get_status('applications'), [])
+ self.assertEqual(len(scc.sent), 2)
+
+ def test_6a88_after_a_page_keeps_that_page(self):
+ """6A88 is "no more matches" after we have data, we're done"""
+ sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6a88')], card_data=CARD_DATA_V22)
+ self.assertEqual(self._aids(sd.get_status('applications')), ['a000000151'])
if __name__ == "__main__":
unittest.main()
--
To view, visit https://gerrit.osmocom.org/c/pysim/+/43552?usp=email
To unsubscribe, or for help writing mail filters, visit https://gerrit.osmocom.org/settings?usp=email
Gerrit-MessageType: newchange
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: I74cd2bd47617d616bede6453397f544cde5abcb7
Gerrit-Change-Number: 43552
Gerrit-PatchSet: 1
Gerrit-Owner: Hoernchen <ewild(a)sysmocom.de>
Hoernchen has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/43553?usp=email )
Change subject: pySim-shell: let --apdu-trace cover the session
......................................................................
pySim-shell: let --apdu-trace cover the session
pySim has two APDU tracers: the StdoutApduTracer that --apdu-trace
installs in the transport for every program using pySim.transport, and
pySim-shell Cmd2ApduTracer for "set apdu_trace true", which prints with
cmd2. The command line option exists because the setting
applies too late to see the pySim startup APDUs.
The guard in init_reader() exists but is useless:
if opts.apdu_trace and not 'apdu_tracer' in kwargs:
no caller supplies a tracer that way, every tool calls init_reader()
without one?.
The current help string does not say "trace or maybe not lol" so set
the setting from the transport rather than hardcoding False, so the
option implies "set apdu_trace true", and it finally works as advertised.
Change-Id: I83469e15f4cdd67418cd11df3869f2536105dbc8
---
M pySim-shell.py
1 file changed, 1 insertion(+), 1 deletion(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/53/43553/1
diff --git a/pySim-shell.py b/pySim-shell.py
index d31cf6a..8e490f6 100755
--- a/pySim-shell.py
+++ b/pySim-shell.py
@@ -101,7 +101,7 @@
self.numeric_path = False
self.conserve_write = True
self.json_pretty_print = True
- self.apdu_trace = False
+ self.apdu_trace = getattr(sl, 'apdu_tracer', None) is not None
self.apdu_strict = False
self.add_settable(cmd2.Settable('numeric_path', bool,
--
To view, visit https://gerrit.osmocom.org/c/pysim/+/43553?usp=email
To unsubscribe, or for help writing mail filters, visit https://gerrit.osmocom.org/settings?usp=email
Gerrit-MessageType: newchange
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: I83469e15f4cdd67418cd11df3869f2536105dbc8
Gerrit-Change-Number: 43553
Gerrit-PatchSet: 1
Gerrit-Owner: Hoernchen <ewild(a)sysmocom.de>
Hoernchen has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/43551?usp=email )
Change subject: transport: stop the T=0 layer from breaking GP 6310
......................................................................
transport: stop the T=0 layer from breaking GP 6310
TS 102 221 section 7.3.1.1.4 clause 4b lets the card answer a case #4
command TPDU with a 62xx/63xx warning, upon which the terminal sends a
dummy GET RESPONSE to obtain the 61xx that announces the response length.
__send_apdu_T0() applies that unconditionally, to the status word that
terminates a GET RESPONSE...
That is not "redundant", as per GP CS v2.3 section 11.4.3.1 the
GP GET STATUS (80 F2) answers 6310 to say "additional matches, reissue
the command with P2 bit 1 set", so for example a paginated registry
listing exchange looks like this:
84f22000024f00 => 61e4 first match, e4 bytes waiting
84c00000e4 => <page 1> 6310 more matches pending
84c0000000 => 6982 <- unsolicited, card rejects it
The card rejects the unsolicited GET RESPONSE with 6982, our SCP02
session breaks, and the next command fail with 6985. The 6310 never
reaches ADF_SD.get_status() either, so the pagination loop exits after
page 1 and prints a sliently truncated listing. Observed with a SJA5.
Fix by turning clause 4b würgaround into what it should be: a one shot
reaction to the SW returned for the command TPDU.
While at it, stop ADF_SD.get_status() from silently returning a truncated
registry: it treated every status word other than 6310 as
"nothing more to report". 6A88 is now the explicit empty result and anything
else raises, so a partial listing can no longer silently pass.
Adds unit tests for all of that so we dont break basic T0 things.
Change-Id: I10f8afa8dd5623a49a6a0e7132607b3a1fad2d8c
---
M pySim/global_platform/__init__.py
M pySim/transport/__init__.py
M tests/unittests/test_globalplatform.py
M tests/unittests/test_transport.py
4 files changed, 299 insertions(+), 6 deletions(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/51/43551/1
diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py
index a450561..80772fe 100644
--- a/pySim/global_platform/__init__.py
+++ b/pySim/global_platform/__init__.py
@@ -742,17 +742,23 @@
while True:
hdr = "80F2%s%02x%02x" % (subset_hex, p2, len(cmd_data))
data, sw = self._cmd.lchan.scc.send_apdu(hdr + b2h(cmd_data) + "00")
+ if sw == '6a88':
+ # "Referenced data not found": nothing (more) matches the requested subset and AID
+ # search qualifier. That is empty, not error?
+ return grd_list
+ if sw not in ['9000', '6310']:
+ # Never return a silently truncated registry
+ raise SwMatchError(sw, '9000/6310')
remainder = h2b(data)
while len(remainder):
# tlv sequence, each element is one GpRegistryRelatedData()
grd = GpRegistryRelatedData()
_dec, remainder = grd.from_tlv(remainder)
grd_list.append(grd)
- if sw != '6310':
+ if sw == '9000':
return grd_list
- else:
- p2 |= 0x01
- return grd_list
+ # GPC CardSpec v2.3, section 11.4.3.1, 6310 == additional matches.
+ p2 |= 0x01
set_status_parser = argparse.ArgumentParser()
set_status_parser.add_argument('scope', choices=list(SetStatusScope.ksymapping.values()),
diff --git a/pySim/transport/__init__.py b/pySim/transport/__init__.py
index 4fb38f1..13e0c42 100644
--- a/pySim/transport/__init__.py
+++ b/pySim/transport/__init__.py
@@ -331,6 +331,18 @@
# correctly the Le byte (usually 0x00) must be present, is often forgotten. To avoid problems with
# legacy scripts that use raw APDU strings, we will still loosely apply GET RESPONSE based on what
# the status word indicates. Unless the user explicitly enables the strict mode (set apdu_strict true)
+ #
+ # The dummy GET RESPONSE of clause 4b (see below) is one shot: it turns a warning SW into the 61xx
+ # that announces the response length. It is only ever a valid reaction to the SW returned for the
+ # _command_ TPDU. Once a response has been fetched there is nothing left to announce, so a warning
+ # SW is the final result of the command and has to be passed on to the caller unmodified.
+ #
+ # This matters because the 62xx/63xx range is not exclusive to ETSI TS 102 221.
+ # GP CS v2.3.1 section 11.4.3.2 table 11-38 GP GET STATUS (80 F2) answers
+ # 6310 "more data available", meaning "reissue with P2 bit 1 set" as per section 11.4.2.2 table 11-34
+ # rather than "response data is waiting". Trying a random GET RESPONSE at that point
+ # makes the card answer 6982 and tears down the whole SCP session and following commands fail with 6985.
+ dummy_gr_allowed = not data
while True:
if sw in ['9000', '9100']:
# A status word of 9000 (or 9100 in case there is pending data from a proactive SIM command)
@@ -343,7 +355,7 @@
# word. (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4a and 3GPP TS 51.011 9.4.1 and
# ISO/IEC 7816-4, Table 5)
le_gr = sw[2:4]
- elif sw[0:2] in ['62', '63']:
+ elif sw[0:2] in ['62', '63'] and dummy_gr_allowed:
# There are corner cases (status word is 62xx or 63xx) where the UICC/eUICC/SIM asks us
# to send a dummy GET RESPONSE command. We send a GET RESPONSE command with a length of 0.
# (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4b and ETSI TS 151 011, section 9.4.1)
@@ -358,6 +370,7 @@
data_gr, sw = self.send_tpdu(tpdu_gr)
log.debug("T0: GET RESPONSE TPDU: %s => %s %s", tpdu_gr, data_gr or "(no data)", sw or "(no status word)")
data += data_gr
+ dummy_gr_allowed = False
if sw[0:2] == '6c':
# SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding
tpdu_gr = prev_tpdu[0:8] + sw[2:4]
diff --git a/tests/unittests/test_globalplatform.py b/tests/unittests/test_globalplatform.py
index 78a195e..2dbec70 100644
--- a/tests/unittests/test_globalplatform.py
+++ b/tests/unittests/test_globalplatform.py
@@ -713,5 +713,73 @@
self.assertLessEqual(wrapped[4], 255)
+class _FakeScc:
+ """mock lchan.scc: replays scripted (data, sw) pairs + records the APDUs sent."""
+
+ def __init__(self, responses):
+ self._responses = list(responses)
+ self.sent = []
+
+ def send_apdu(self, apdu):
+ self.sent.append(apdu.lower())
+ if not self._responses:
+ raise AssertionError('get_status sent unexpected APDU: %s' % apdu)
+ return self._responses.pop(0)
+
+
+class GetStatus_Pagination_Test(unittest.TestCase):
+ """GP CS v2.3 section 11.4.3.1 GET STATUS pagination test
+
+ Card answers 6310 when further matches are pending; command reissued with
+ P2 bit 1 "next occurrence" set. Tied to T=0 handling pySim/transport, which
+ used to swallow that 6310 and replied with GET RESPONSE, so page 2 was never fetched."""
+
+ ENTRY_1 = 'e3074f05a000000151'
+ ENTRY_2 = 'e3074f05a000000152'
+
+ def _sd(self, responses):
+ scc = _FakeScc(responses)
+ cmd = type('_Cmd', (), {'lchan': type('_Lchan', (), {'scc': scc})()})()
+ # cmd2 strikes again, CommandSet exposes _cmd as a read only property, needs shadowing
+ _SD = type('_SD', (ADF_SD.AddlShellCommands,), {'_cmd': cmd})
+ return _SD.__new__(_SD), scc
+
+ def _aids(self, grd_list):
+ return [b2h(grd.to_dict()['gp_registry_related_data'][0]['application_aid']) for grd in grd_list]
+
+ def test_single_page(self):
+ sd, scc = self._sd([(self.ENTRY_1, '9000')])
+ grd_list = sd.get_status('applications')
+ self.assertEqual(scc.sent, ['80f24002094f005c054f9f70c5cc00'])
+ self.assertEqual(self._aids(grd_list), ['a000000151'])
+
+ def test_two_pages(self):
+ """6310 -> reissue with P2 bit 1 set -> 9000, both pages in result"""
+ sd, scc = self._sd([(self.ENTRY_1, '6310'), (self.ENTRY_2, '9000')])
+ grd_list = sd.get_status('applications')
+ self.assertEqual(scc.sent, ['80f24002094f005c054f9f70c5cc00',
+ '80f24003094f005c054f9f70c5cc00'])
+ self.assertEqual(self._aids(grd_list), ['a000000151', 'a000000152'])
+
+ def test_three_pages_keep_p2_next_occurrence(self):
+ sd, scc = self._sd([(self.ENTRY_1, '6310'), (self.ENTRY_2, '6310'), (self.ENTRY_1, '9000')])
+ grd_list = sd.get_status('applications')
+ self.assertEqual([a[6:8] for a in scc.sent], ['02', '03', '03'])
+ self.assertEqual(len(grd_list), 3)
+
+ def test_no_match_returns_empty(self):
+ """6A88 "referenced data not found" is empty result not failure."""
+ sd, _scc = self._sd([('', '6a88')])
+ self.assertEqual(sd.get_status('applications'), [])
+
+ def test_unexpected_sw_is_not_silently_truncated(self):
+ """partial is not complete result"""
+ sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6982')])
+ with self.assertRaises(SwMatchError) as ctx:
+ sd.get_status('applications')
+ self.assertEqual(ctx.exception.sw_actual, '6982')
+
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unittests/test_transport.py b/tests/unittests/test_transport.py
index b2c7f08..268ccb2 100644
--- a/tests/unittests/test_transport.py
+++ b/tests/unittests/test_transport.py
@@ -1,9 +1,28 @@
#!/usr/bin/env python3
+"""Transport (as in t0/t1) tests"""
+
+# (C) 2026 by sysmocom - s.f.m.c. GmbH <info(a)sysmocom.de>
+#
+# Author: Eric Wild
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
import unittest
from osmocom.utils import h2b, b2h
from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
-from pySim.transport import ProactiveHandler
+from pySim.transport import ProactiveHandler, LinkBaseTpdu
def _send_short_message_pcmd():
@@ -51,5 +70,192 @@
self.assertIn('CommandDetails', str(ctx.exception))
+class FakeTpduLink(LinkBaseTpdu):
+ """mock LinkBaseTpdu that replays a list of (data, sw) responses + records every TPDU that
+ the T=0 state machine sends. Secretly sending more TPDUs than intended is the error,
+ designed to test "unsolicited GET RESPONSE" mishaps"""
+
+ def __init__(self, responses):
+ super().__init__()
+ self._responses = list(responses)
+ self.sent = []
+
+ def send_tpdu(self, tpdu):
+ self.sent.append(tpdu.lower())
+ if not self._responses:
+ raise AssertionError('T=0 layer sent an unpexpected TPDU: %s (total so far: %s)'
+ % (tpdu, self.sent))
+ return self._responses.pop(0)
+
+ def __str__(self):
+ return 'FakeTpduLink'
+
+ def wait_for_card(self, timeout=None, newcardonly=False):
+ pass
+
+ def connect(self):
+ pass
+
+ def get_atr(self):
+ return '3b00'
+
+ def disconnect(self):
+ pass
+
+ def _reset_card(self):
+ pass
+
+
+# GP GET STATUS, wrapped in SCP02 CLA 84, Case #4.
+GET_STATUS = '84f22002094f005c054f9f70c5cc' + '00'
+GET_STATUS_TPDU = '84f22002094f005c054f9f70c5cc'
+
+# generic #4 SELECT by DF name command
+CASE4 = '00a4040c07a0000000871002' + '00'
+CASE4_TPDU = '00a4040c07a0000000871002'
+
+
+class Test_send_apdu_T0(unittest.TestCase):
+ """regression tests for the T=0 state machine in LinkBaseTpdu.__send_apdu_T0()"""
+
+ def _exchange(self, apdu, responses, strict=True, protocol=0):
+ link = FakeTpduLink(responses)
+ link.apdu_strict = strict
+ link.set_tpdu_format(protocol)
+ data, sw = link._send_apdu(apdu)
+ return link, data, sw
+
+ #### TS 102 221 section 7.3.1.1 TPDU construction
+
+ def test_case1_gets_le_appended(self):
+ link, data, sw = self._exchange('00200001', [('', '9000')])
+ self.assertEqual(link.sent, ['0020000100'])
+ self.assertEqual((data, sw), ('', '9000'))
+
+ def test_case3_passed_through_unmodified(self):
+ apdu = '00200001081122334455667788'
+ link, _data, sw = self._exchange(apdu, [('', '9000')])
+ self.assertEqual(link.sent, [apdu])
+ self.assertEqual(sw, '9000')
+
+ def test_case4_le_stripped(self):
+ link, data, sw = self._exchange(CASE4, [('', '9000')])
+ self.assertEqual(link.sent, [CASE4_TPDU])
+ self.assertEqual((data, sw), ('', '9000'))
+
+ #### TS 102 221 7.3.1.1.4 4a GP GET RESPONSE for 61xx / 9fxx
+
+ def test_61xx_fetches_response(self):
+ link, data, sw = self._exchange(CASE4, [('', '6103'), ('a1b2c3', '9000')])
+ self.assertEqual(link.sent, [CASE4_TPDU, '00c0000003'])
+ self.assertEqual((data, sw), ('a1b2c3', '9000'))
+
+ def test_61xx_chained(self):
+ link, data, sw = self._exchange(CASE4,
+ [('', '6102'), ('aabb', '6102'), ('ccdd', '9000')])
+ self.assertEqual(link.sent, [CASE4_TPDU, '00c0000002', '00c0000002'])
+ self.assertEqual((data, sw), ('aabbccdd', '9000'))
+
+ def test_9fxx_fetches_response(self):
+ link, data, sw = self._exchange(CASE4, [('', '9f04'), ('deadbeef', '9000')])
+ self.assertEqual(link.sent, [CASE4_TPDU, '00c0000004'])
+ self.assertEqual((data, sw), ('deadbeef', '9000'))
+
+ def test_get_response_inherits_cla(self):
+ """GET RESPONSE must reuse CLA of command"""
+ link, _data, _sw = self._exchange(GET_STATUS, [('', '6102'), ('aabb', '9000')])
+ self.assertEqual(link.sent, [GET_STATUS_TPDU, '84c0000002'])
+
+ def test_9100_terminates(self):
+ """9100 is final status word, not fetch trigger"""
+ link, data, sw = self._exchange(CASE4, [('', '9100')])
+ self.assertEqual(link.sent, [CASE4_TPDU])
+ self.assertEqual((data, sw), ('', '9100'))
+
+ def test_error_sw_terminates(self):
+ link, data, sw = self._exchange(CASE4, [('', '6982')])
+ self.assertEqual(link.sent, [CASE4_TPDU])
+ self.assertEqual((data, sw), ('', '6982'))
+
+ def test_no_status_word_raises(self):
+ with self.assertRaises(ValueError):
+ self._exchange(CASE4, [('', None)])
+
+ #### TS 102 221 7.3.1.1.4 4b dummy GET RESPONSE
+
+ def test_clause_4b_warning_before_data_bootstraps(self):
+ """warning SW returned for the _command_ TPDU triggers dummy GET RESPONSE (Le=00)"""
+ for warn in ('6200', '6281', '62f1', '6300', '63f1'):
+ with self.subTest(sw=warn):
+ link, data, sw = self._exchange(CASE4,
+ [('', warn), ('', '6103'), ('a1b2c3', '9000')])
+ self.assertEqual(link.sent, [CASE4_TPDU, '00c0000000', '00c0000003'])
+ self.assertEqual((data, sw), ('a1b2c3', '9000'))
+
+ def test_warning_after_data_terminates(self):
+ """Once the response has been fetched a warning status word is the final result of the command"""
+ for warn in ('6281', '6283', '63c2', '6300', '62f1', '63f1', '6310'):
+ with self.subTest(sw=warn):
+ link, data, sw = self._exchange(CASE4, [('', '6102'), ('aabb', warn)])
+ self.assertEqual(link.sent, [CASE4_TPDU, '00c0000002'])
+ self.assertEqual((data, sw), ('aabb', warn))
+
+ def test_no_dummy_get_response_when_command_already_returned_data(self):
+ """warning that arrives together with response data (for example 6282 on a case #2 read) is final, too"""
+ link, data, sw = self._exchange('00b0000004', [('01020304', '6282')], strict=False)
+ self.assertEqual(link.sent, ['00b0000004'])
+ self.assertEqual((data, sw), ('01020304', '6282'))
+
+ def test_repeated_warning_does_not_loop(self):
+ """warning -> dummy GET RESPONSE -> warning again must terminate"""
+ link, data, sw = self._exchange(CASE4, [('', '6281'), ('', '6281')])
+ self.assertEqual(link.sent, [CASE4_TPDU, '00c0000000'])
+ self.assertEqual((data, sw), ('', '6281'))
+
+ #### fixed GlobalPlatform GET STATUS pagination
+
+ def test_gp_6310_reaches_the_caller(self):
+ """GET STATUS answers 6310"""
+ link, data, sw = self._exchange(GET_STATUS, [('', '6104'), ('e3024f00', '6310')])
+ self.assertEqual(link.sent, [GET_STATUS_TPDU, '84c0000004'])
+ self.assertEqual((data, sw), ('e3024f00', '6310'))
+
+ def test_gp_get_status_two_pages(self):
+ """Both GET STATUS pages, page 1 6310, reissued with P2 bit 1 set, page 2 9000."""
+ page1 = self._exchange(GET_STATUS, [('', '6104'), ('e3024f00', '6310')])
+ self.assertEqual(page1[1:], ('e3024f00', '6310'))
+ page2 = self._exchange('84f22003094f005c054f9f70c5cc00',
+ [('', '6104'), ('e3024f01', '9000')])
+ self.assertEqual(page2[0].sent, ['84f22003094f005c054f9f70c5cc', '84c0000004'])
+ self.assertEqual(page2[1:], ('e3024f01', '9000'))
+
+ #### 6cxx and apdu_strict
+
+ def test_6cxx_reissues_command_with_correct_length(self):
+ link, data, sw = self._exchange('00b0000000', [('', '6c04'), ('01020304', '9000')])
+ self.assertEqual(link.sent, ['00b0000000', '00b0000004'])
+ self.assertEqual((data, sw), ('01020304', '9000'))
+
+ def test_strict_mode_does_not_auto_fetch_for_case3(self):
+ apdu = '00200001081122334455667788'
+ link, data, sw = self._exchange(apdu, [('', '6104')], strict=True)
+ self.assertEqual(link.sent, [apdu])
+ self.assertEqual((data, sw), ('', '6104'))
+
+ def test_non_strict_mode_auto_fetches_for_case3(self):
+ apdu = '00200001081122334455667788'
+ link, data, sw = self._exchange(apdu, [('', '6104'), ('aabbccdd', '9000')], strict=False)
+ self.assertEqual(link.sent, [apdu, '00c0000004'])
+ self.assertEqual((data, sw), ('aabbccdd', '9000'))
+
+ #### T=1 briefly
+
+ def test_t1_is_passed_through(self):
+ """T=1 has no GET RESPONSE"""
+ link, data, sw = self._exchange(GET_STATUS, [('e3024f00', '6310')], protocol=1)
+ self.assertEqual(link.sent, [GET_STATUS.lower()])
+ self.assertEqual((data, sw), ('e3024f00', '6310'))
+
+
if __name__ == "__main__":
unittest.main()
--
To view, visit https://gerrit.osmocom.org/c/pysim/+/43551?usp=email
To unsubscribe, or for help writing mail filters, visit https://gerrit.osmocom.org/settings?usp=email
Gerrit-MessageType: newchange
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: I10f8afa8dd5623a49a6a0e7132607b3a1fad2d8c
Gerrit-Change-Number: 43551
Gerrit-PatchSet: 1
Gerrit-Owner: Hoernchen <ewild(a)sysmocom.de>