Compare commits

..

57 Commits

Author SHA1 Message Date
catarrh 59630623e9 Merge remote-tracking branch 'origin/main' 2026-09-16 23:53:22 +03:00
catarrh dd7eecb279 custom files: update the section comment to the new parent rules 2026-09-16 23:48:44 +03:00
catarrh b0b8d02fe7 custom files: any DF parent path at any depth (v2.2.11)
The parent field was a select over custom DFs only, so a standard DF (e.g.
MF/7F20) could not be a parent and canonical entries under one were dropped
on import. It is now a free-text path with suggestions from the root, custom
DFs and DFs of the loaded tree (any depth); a bare FID chain is completed
from the root selector. Validation accepts standard or not-yet-seen parents
(flagged in the list), rejects a parent known to be an EF, and import keeps
such entries. Custom-file edits re-inject the loaded tree: new nodes appear
at once, renamed model nodes are restored and injected nodes removed when
their entry goes away. Help updated (the form lives in Profiler, not Card
reader). SW cache otaman-v176.
2026-09-16 23:47:30 +03:00
catarrh 0698c9e90c terminal profile: move Cancel/Apply to the top of the dialog
The actions sat below the note line and the (long, 45vh) bits grid, so they
needed scrolling to reach. They now sit to the right of the preset selector
and hex field, aligned with the preset row; the bit grid is unchanged.
2026-09-16 23:26:51 +03:00
catarrh c734b46731 file manager: tree height fits the free viewport space (sw otaman-v175)
The tree box was capped at a fixed 65vh, which ignored the chrome above it
(header, tabs, status, sub-tabs, sort/probe row), so the page itself grew a
scrollbar once the tree hit its cap. pysimFsFitTree() now caps it to
innerHeight - top - 12px at runtime (the inline 65vh stays as the no-JS
fallback), re-run on resize, on render, on sub-tab/tab entry and when the
probe status line appears.
2026-09-16 23:26:47 +03:00
catarrh 8e68673509 terminal profile: spec-audited bits, fixed layout, more presets (v2.2.10)
TERMINAL PROFILE form (Phone simulator):
- The compact block no longer shows the hex value (no room); Send +
  Configure + status remain. Preset selector sits above the hex field.
- Bits are never spread horizontally: each byte is one vertical list.
  Byte blocks are placed by the fixed TP_LAYOUT groups - bytes 1-12 in
  2 columns, 13-16 in 4, 17-18 in 2, 19-21 in 3, 22-25 in 2, 26-28 in 3,
  29-30 in 2, 31+ one per row - in strict byte order.

Bit descriptions audited against the pinned specs:
- Table regenerated from ETSI TS 102 223 V18.3.0 5.2 (bytes 1-39) with
  the 3GPP-defined bits named per ETSI TS 131 111 V18.12.0 5.2; the
  "reserved by 3GPP" placeholders are gone (tests guard this).
- Fixes found in the audit: byte 7 b6 PERFORM CARD APDU (pySim had
  RESET), MO short message control by USIM, USSD string DO support in
  Call Control by USIM, the earlier "Data available" typo.
- Presets: project default + Quectel example + Samsung S21+ 5G, Samsung
  A55 5G, Xiaomi Redmi Note 10 LTE, Sony Xperia Z5c LTE, Huawei
  E5573c/M150 (LTE), Huawei E173 3G modem, Nokia 7210 2G (9 total).

Also fixes a page-load TDZ regression: the custom-files init (which
calls t()) now runs after the language init (guard test added).

SW cache otaman-v174; help EN/RU updated; tests 236 Python + 374 frontend.
2026-09-16 22:15:01 +03:00
catarrh aa33d25466 terminal profile at runtime + canonical custom-file paths (v2.2.3)
TERMINAL PROFILE:
- GET /api/terminal-profile returns the profile in effect + the CLI default;
  POST /api/terminal-profile validates ({profile}, hex, even, 1-255 bytes),
  stores it in memory and re-sends it, resetting the STK session like
  /api/rescue (the shared _resend_terminal_profile helper; rescue now
  delegates to it). __main__ keeps server.cli_terminal_profile.
- Phone tab: a TERMINAL PROFILE block next to STATUS and Polling with the
  current hex/byte count, Send (re-send) and Configure. The Configure
  dialog has a device-model preset selector, a hex field and a per-bit
  form generated from a 264-entry table for TS 102 223 V18.3.0 5.2 bytes
  1-33 (pySim's table as scaffold, later bytes/3GPP bits added from the
  spec; beyond the table generic RFU labels). Form <-> hex sync both
  ways, hex authoritative, bits preserved. Apply posts the new value.
- Presets: Xiaomi Mi A1 (project default), Quectel GSM module example.
  In-memory only, no persistence.

Custom files:
- Canonical paths rooted at MF / ADF.USIM / ADF.ISIM; entries are
  {path, name, kind}. The editor now uses root + parent-DF selector +
  4-hex FID + alias, requires the parent DF to be defined first, rejects
  duplicates, rewrites descendants when a DF's path changes and cascades
  deletes after a confirmation.
- Legacy forms are normalized on load/import (3F00/... -> MF/..., relative
  a153/4954 resolved against the custom DFs); unresolvable entries are
  dropped and reported in the list.
- Tree injection matches by exact parent path via the new pysimFsNodePath
  (same-FID DFs under different parents no longer collide);
  profilerCustomNameForPath uses the same normalization.

SW cache otaman-v167; help EN/RU + docs/api.md + AGENTS updated.
Tests: 236 Python + 371 frontend.
2026-09-16 20:46:40 +03:00
catarrh 20fbfd99a5 scp81 passthru, proactive decoders, ADM badge, file manager layout (v2.2.1)
SCP81:
- New listener mode "passthru": no local listener - the card's BIP
  channels connect straight to a configured external platform
  (host/port required), which terminates TLS and runs the dialog.
  The status API reports mode/target (_SCP81_MODE/_SCP81_TARGET) and
  clears them on stop. PWA mode select, hint, Start validation; TLS PSK
  stays the default.

Proactive command decoding (Phone tab log):
- SEND SHORT MESSAGE (0x13) is now decoded: alpha, address (TON/NPI +
  number), 3GPP-SMS TPDU (type, TP-MR, TP-DA, TP-PID 0x7F flagged as
  SIM data download, TP-DCS, TP-VP, TP-UDL), UDH concatenation IEs, and
  the TP-UD as text (GSM-7 with a septet unpacker, UCS2, 8-bit) or as a
  TS 31.115 secured packet for PID 0x7F; malformed TPDUs fall back to
  the raw hex line.
- PROVIDE LOCAL INFORMATION qualifier names completed per TS 102 223
  V18.3.0: ESN (07), MEID (0B), Supported RATs (1A); 05 relabelled
  "Reserved for GSM (Timing Advance)". Fixed in the server dict and
  both frontend tables.

Header:
- Compact ADM badge next to the card indicator: "ADM ✓" green when
  pySim's adm_verified is set, "ADM ✗" red otherwise, hidden without a
  card session or when the server is down; updated ahead of the card
  state-key early return so it never disturbs the connect/reset flow.

File manager:
- Sort pills (FID/Name), Probe all files button and progress line are
  pinned above the tree instead of scrolling with it; the tree box cap
  grows from 420px to 65vh.

SW cache otaman-v165; help EN/RU updated; tests 234 Python + 361 frontend.
2026-09-16 15:29:01 +03:00
catarrh 8c90958718 scp81: PSK by identity, scripts page, exact snapshots, SCP80 LOAD fit (v2.2.0)
Cards / SCP81:
- Cards is a top-level tab; presets gain PSK identity + key, HTTP-OTA
  column, Edit/Update and a live PSK-map push into a running listener.
- SCP81 has Listener/Scripts pills; scripts are named local APDU lists
  (Empty / Explore / Install from .cap / Delete templates), sent to the
  server explicitly at start. The listener takes mode/host/port/script
  only; PSK inputs and the .cap row are gone.
- Multi-PSK TLS listener: identity -> key lookup from the card presets
  (POST /api/scp81/psk-map updates a running listener), unknown
  identities log tls-psk-unknown and fail; handshake logs carry psk_match.
- Script engine: execution tracking (next/done/pending/results), a
  resumed dialog sends only the unexecuted tail (unreported APDU is
  resent), a fresh dialog restarts, listing continuation pages are
  tracked separately (pending.pages/complete). Restart script button.
- POST /api/scp81/gen-install replaces the SCP81 ram-install queueing
  (generation only; the .cap is never stored).

Profiler / snapshots:
- Snapshot comparison is always exact (mask checkboxes removed; the
  first-4-bytes mask remains a profile-creation option).
- "matching records" line shows count + #record numbers.
- New Clone action: copy named "Copy of <profile>", opened in the editor.
- Matched-record count/numbers fix ("1 из 8 (#8)").

SCP80:
- Configurable / auto-fitted LOAD block size: each LOAD APDU encodes into
  one SMS (pySim rejects secured packets above 140 octets, so a 240-byte
  block could never be sent). Response reports the effective size and
  clamps; encode failures are reported per step with the pySim message.

SW cache otaman-v161; docs/api.md, scp81-findings and help EN/RU updated.
Tests: 226 Python + 356 frontend.
2026-09-16 13:32:26 +03:00
catarrh ef8b651f28 scp80: keep the card preset selected after a send (v2.1.18)
cardsSave() calls cardsRebuildSelect(), and the innerHTML rebuild reset the
dropdown to its first option ("- Select card -"). Since the counter sync
saves after every send, the selection was lost each time and had to be
re-picked manually. The rebuild now restores the previous selection
(guarded by the preset still existing). Service worker v155.
2026-09-16 08:44:44 +03:00
catarrh f6c1dd0751 docs: installed applet verified in all registries
explore with the fixed response parser ran 18 commands / 10 continuation
pages (real 63 10/9000 statuses) and shows the installed applet:
AA1902BC22580101 (life=07 selectable, elf=AA1902BC225801) in the
applications listing, AA1902BC225801 (life=01) in the ELF registry and with
its module in the ELF+modules listing.
2026-09-16 08:42:13 +03:00
catarrh e957757fda scp81: BER length for the Response Scripting template TLVs (v2.1.17)
The R-APDU TLV length was read as a raw byte, so a listing page above 127
bytes (AF 80 23 81 FC <252 bytes> 00 00) was cut to its first 127 bytes with
a bogus status word (the data's last two bytes: CAFE/0001/9F70 instead of
the real 63 10 "more data"). The bogus SW also stopped the SW CAFE/6310
pagination, which is why later registry entries - e.g. the installed package
AA1902BC225801 - never showed up. Uses httpota.ber_len_read now.

Tests: 250-byte long-form page and short-form regression (204 python);
findings updated; service worker v154
2026-09-16 08:29:00 +03:00
catarrh c341571300 scp81: correct GET STATUS pagination and registry P1s (v2.1.16)
- continuation repeats the SAME GET STATUS command with P2.b1 set (the
  pagination state lives in the card); changing the 4F criterion is a match
  filter, not a position - P2=03 with the last AID is rejected with 6A80 and
  P2=02 with it returns that single match (the earlier duplicate)
- handle the standard "more data available" warning SW 63 10 (Table 11-38)
  in addition to the live card's proprietary CA FE
- explore script: P1=40 is applications+SDs, P1=20 the ELF registry, P1=10
  ELF+modules (Table 11-33) - the ELF-only registry was never queried, which
  hid the installed package; labels and the results decoder show C4 (ELF AID)
  and CC (SD AID) too
- UICC_SPECS.md: GET STATUS P1/P2 tables made explicit with the pagination
  rule, plus BER length coding notes for the scripting templates and the
  TS 102 223 channel data TLV (the two >127-byte traps)

201 python + 346 frontend; service worker v153
2026-09-16 08:22:59 +03:00
catarrh 3403abd6b9 scp81: continuation pages use GET STATUS P2=03 (next occurrence) (v2.1.15)
P2=02 means "get first or all occurrence(s)" (Table 11-34), so every
continuation re-returned the first listing (the criterion's single match),
pagination stopped after one extra page and the installed package
AA1902BC225801 never showed up in the ELF registry. P2=03 = "get next
occurrence(s)" is the correct value for the SW CAFE continuation.

Tests updated with the new continuation bytes; findings documented;
service worker v152
2026-09-16 08:12:36 +03:00
catarrh 5e3fac260b docs: RAM install over SCP81 live-verified end-to-end
INSTALL [for load] -> LOAD x6 -> INSTALL [for install] (SW 9000) against the
live card. Records the parameter-less 6A80 finding and the working
INSTALL [for install] command built from the Remote APDU -> RAM form and
queued via "Queue in SCP81".
2026-09-16 08:10:24 +03:00
catarrh b811906751 scp81: queue explicit commands, expanded-format output for the RAM chain (v2.1.14)
- POST /api/scp81/queue takes an explicit APDU list (or single APDU) and
  queues it as the SCP81 script; entries that already are Command Scripting
  templates (AA.../AE80..., the expanded format) are sent verbatim instead of
  being wrapped again
- Remote APDU -> RAM chain: "To expanded" builds each command in the
  TS 102 226 expanded form (AA definite / AE80 indefinite selector) and
  "Queue in SCP81" queues the built commands for the next card POST, so the
  full-featured RAM/INSTALL [for install] form (AIDs, privileges, TK/STK
  parameters) can drive the HTTP OTA install
- RU strings; api.md; service worker v151
2026-09-16 08:05:57 +03:00
catarrh e32a6e17e4 scp81: expose install/STK parameters for the RAM install (v2.1.13)
The SCP81 install row now carries the same fields as the SCP80/RAM form:
install parameters (default C900), STK parameters (CA TLV) and make
selectable. An UICC applet install can require STK parameters; without them
the card answers INSTALL [for install] with SW 6A80 (incorrect parameters in
data field). RU strings added; service worker v150.
2026-09-16 07:57:59 +03:00
catarrh ff7a1ad5d3 scp81: fix RAM LOAD block splitting (overlapping 1-byte shifts) (v2.1.12)
The block slicer used the block number as a character offset
(loadfile_tlv[i * 2:(i + 240) * 2]), so every LOAD block after the first was
a 1-byte-shifted copy of the previous one - the cap header repeated every
239 bytes on the wire. A live install accepted three blocks, failed block 4
with SW 6400, then 6985, and INSTALL [for install] answered 6A88. The same
slicing was inherited by the SCP81 helper from the SCP80 path, so
multi-block caps could not install there either; both are fixed.

Tests: blocks are consecutive and reassemble the C4 TLV byte-for-byte
(200 python); findings updated; service worker v149.
2026-09-16 07:51:59 +03:00
catarrh 92f646df08 scp81: BER-encode the Command Scripting template lengths (v2.1.11)
The C-APDU TLV length was written as a raw byte: a 245-byte LOAD command
produced 'AE 80 22 F5 ...', which BER reads as a long-form marker, so the
card mis-parsed every script command over 127 bytes. Small commands worked,
which made a RAM install look alive: the card answered the LOAD steps with a
degenerate 'AF 80' body (no R-APDU), and the final INSTALL [for install]
failed with 6A88 because the package never loaded.

Both the indefinite ('22' TLV) and definite ('AA' outer) lengths are now
BER-encoded (same rule as the BIP channel data TLV fix). Tests: 245-byte
LOAD body, short form, definite variant (200 python, 346 frontend);
findings doc updated; service worker v148.
2026-09-16 07:45:14 +03:00
catarrh f411f12c56 scp81: results panel polish - labels, dedupe, 0085 decode (v2.1.10)
- each result group shows the command label (GET DATA FF21, GET STATUS
  P1=80/40/10, GET DATA 0085, INSTALL/LOAD)
- GET STATUS pages deduplicate by AID: the continuation page re-includes its
  search criterion, which made the last entry of every listing appear twice
- the GET DATA 0085 answer is decoded (host/agent/uri, PSK identity +
  KVN/KID from the unframed [14][id][02 KVN/KID] security TLV, retry counter
  and timer, connection block with APN and destination address) instead of a
  truncated hex dump; undecodable results show the full hex now
- tests: admin-params decode with the live sample, command labels
  (346 frontend, 196 python); service worker v147
2026-09-16 07:35:11 +03:00
catarrh 4dd09740b7 scp80: advance the counter after every send and persist it to the preset (v2.1.9)
The counter only advanced when a PoR came back with status por_ok, so
PoR-less sends kept reusing the same counter (the card rejects a repeated
counter for replay protection). It now advances on every successful send
(and shows "CNTR -> ..." in the result line), and the new value is written
into the selected card preset via spCntrSyncPreset(). Manually edited
counters are persisted too (the field's onchange). New spNextCntr() helper
with carry tests; service worker v146.
2026-09-16 07:30:21 +03:00
catarrh 3463a68292 scp81: RAM install over HTTP OTA (v2.1.8)
- shared _cap_apdu_sequence helper (INSTALL [for load] -> 240-byte LOAD
  blocks -> INSTALL [for install]); the SCP80 /api/ram-install path now uses
  it too (one source of truth; byte-level tests pin the APDUs)
- POST /api/scp81/ram-install: parses the .cap server-side and queues the
  APDU sequence as the SCP81 command script (one C-APDU per POST, runs on the
  card's next push); refused while a script is mid-run unless force
- /api/scp81/script reports the script kind (explore/none/custom/ram-install)
- tab: RAM install row (CAP file + SD AID + Queue button); RU strings
- docs: api.md, findings, AGENTS; service worker v145
2026-09-16 02:05:20 +03:00
catarrh e255677dd9 scp81: decoded script-results panel in the tab (v2.1.7)
- results carry the originating APDU, so auto-continued SW CAFE pages group
  under their logical command
- new tab panel: memory pages decode to applets / free NV / free volatile,
  GET STATUS pages decode to AID + lifecycle + privileges (via the existing
  decodePrivileges) + module AIDs, truncated page tails are skipped
- tests: decoders and grouping (frontend 342, python 192)
- service worker v144
2026-09-16 01:59:04 +03:00
catarrh a352d8fe3f scp81: auto-continue SW CAFE listing pages (v2.1.6)
Long GET STATUS listings answer SW CAFE with 127-byte pages. The script
responder now extracts the last complete AID from the page and inserts a
next-occurrence GET STATUS (80F2 <P1> 02 <Lc> 4F <len> <AID> 00) as the
next command, until the listing ends. Pages are logged (script-page), a
repeated page logs script-page-stalled and stops, inserted continuations are
dropped at session start.

Live-verified: ELF registry and applications collected completely in two
pages each (7/7 commands, all script-status ok).

- tests: page parsing (truncated tails, live FC-prefixed junk), continuation
  bytes, auto-insert, stall guard, per-session purge (192 python + 337 frontend)
- UI: page number and script-status log rendering; service worker v143
2026-09-16 01:56:29 +03:00
catarrh 175ca934d8 scp81: PSK TLS server and command scripting (phases B/C) + BIP fix (v2.1.5)
- scp81.py: PSK TLS listener (stdlib ssl PSK callbacks) speaking the GP
  HTTP administration dialog; configurable framing (chunked/Content-Length,
  TLS record split, Apache-style/compact headers, Connection header,
  keep-alive, Next-URI template with %d, TLS version/cipher, answer delay,
  keylog for capture decryption)
- server.py: script responder + Response Scripting parsing (AF/AB, 80/23
  TLVs), memory decoder, SCP81 start options, terminal-side timer
  management, background-mode BIP events, permissive OPEN CHANNEL
- BIP fix: the RECEIVE DATA channel-data TLV length is BER long form
  (36 81 <len>) above 127 bytes; a raw length byte is mis-parsed on the
  card, so the large TLS records never reached its stack (a live card
  fetched the script response and silently never processed it - endless
  resume). The card now executes scripts and returns R-APDUs: memory
  (13 applets, 50646 B NV free, 2402 B volatile), ISD, stored HTTP OTA
  parameters, ELF and application registries
- frontend: SCP81 tab (listener, script selection, HTTP OTA log), phone
  event forms, i18n; service worker v141
- docs: api.md, scp81-findings.md (attempt matrix + root cause analysis);
  tools/scp81_decrypt.py decrypts listener captures via the keylog
- tests: 187 python + 337 frontend
2026-09-16 01:50:29 +03:00
catarrh f07ccc5229 Create .gitattributes 2026-09-14 20:47:45 +03:00
catarrh ea1730b206 scp81: BIP terminal emulation (phase A)
Groundwork for HTTP OTA emulation (GP RAM over HTTP / SCP81): the server
now emulates the terminal side of Bearer Independent Protocol and redirects
the card's TCP channel to a locally configured target.

- new pysim_otaman_server/httpota.py: BER-TLV parser for proactive commands,
  BipTerminal (per-channel TCP client with rx buffering, redirect target,
  bounded event log) and TcpDumpServer (raw capture listener used to
  calibrate the card's TLS ClientHello before the PSK platform exists)
- server.py: BIP commands 0x40-0x43 (+0x44) are handled in
  _handle_proactive_chain with TR payloads matching the captured real
  terminal traces (result first, Channel status with link-established bit,
  Bearer description, Buffer size, Channel data/length); connection
  failures return result 3A/00; names and decoders added for the proactive
  log; /api/scp81/status, /log, /log-clear and /bip (start/stop dump mode)
- tests: TR byte vectors from the traces, TLV parsing, open/send/receive/
  close flow over a local peer, dump listener; 123 Python tests pass
2026-09-13 22:56:48 +03:00
catarrh c9ae494f62 fix: snapshot/profile scans skipped DF subtrees (parent_path off-by-one)
profilerScanCard's walkDir passed each child dir's own segment in
parent_path (dir.parentPath.concat(childSeg) as the child's parent path),
so /api/tree for every DF child walked the target as its own parent and
failed: DF.GSM, DF.TELECOM and DF.GSM-ACCESS subtrees were silently
missing from New snapshot, Profile from card and Profile from snapshot
(ADF children survived only because application names/AIDs resolve
globally).

- walkDir now stores each dir's full path and sends
  parent_path = fullPath.slice(0, -1) (omitted for MF); the child's full
  path is fullPath.concat(aid/fid/name)
- verified against the running server: the walker now enumerates 189 EFs
  and captures 92, matching the file-manager probe exactly (was 111/48)
- test: fake MF -> DF.GSM tree asserts parent_path [undefined, ['MF']]
  and that both subtrees' rules are produced; SW cache v132 -> v133.
2026-09-13 21:39:15 +03:00
catarrh 0370fa58ad release: v2.1.2
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v131 -> v132.
2026-09-13 11:35:02 +03:00
catarrh 2213e25385 ui: generate a profile from a card snapshot
- new 'Profile from snapshot' button in the Profiles toolbar: reuses the
  snapshot picker (profilerSnapshotPickListHtml refactor) and then opens
  the same scan form as Profile from card (profilerScanOpenForm), with
  the profile name prefilled from the snapshot
- new profiler-build target 'profile-snapshot': the form's ignore list,
  mask options and FCP/FCI mode apply to profilerScanSnapshot(), which
  walks the snapshot's captured files instead of the card; uncaptured
  contents produce rules without a content check, record files map to
  exact record content, transparent files honor the masks
- profilerScanStart() pushes the generated profile and opens the editor
  like the live scan; _scanSnapshot is cleared in the finally block
- RU entry 'Профиль из снимка'; tests for the snapshot rule builder
  (exact/mask/ignore/uncaptured/record), the snapshot scan progress, the
  picker wiring and the form title, plus a structural button check;
  help EN/RU and READMEs updated; SW cache v130 -> v131.
2026-09-13 11:32:18 +03:00
catarrh bf8abbb0fd ui: descriptive check-results headers with the check type first
- new profilerResultsHeaderText() builds the header from a structured
  {kind, from, to}: profile checks read 'Profile verification results
  for: <profile> -> <card ICCID | snapshot name>', snapshot comparison
  reads 'Snapshot comparison results: <master> -> <checked>' (the arrow
  matches the one already used in the compare header)
- profilerRunProfile() stores the header and profilerRenderResultsView()
  writes it, so the header follows a language switch and the ICCID stays
  current; RU keys added ('Результаты проверки профиля:',
  'Результаты сравнения снимков:')
- tests: header builder variants + render wiring; flow tests now expect
  header objects; help EN/RU and READMEs updated; SW cache v129 -> v130.
2026-09-13 11:23:59 +03:00
catarrh a9a4d03b09 ui: label check reports with the profile, card ICCID / snapshot name
- new profilerLabelText() resolves expected/actual labels at render time;
  profilerRenderReport and fcpDiffHtml (including the decode-failure
  notes) use it, so a language switch keeps the labels localized
- Check card: reads EF.ICCID via /api/read (MF/2FE2) and labels the
  report 'expected (<profile>)' / 'actual (<card ICCID>)'; the results
  title becomes '<profile> — <ICCID>' (falls back to the plain title and
  'actual' when EF.ICCID is unreadable)
- Check card snapshot: labels 'expected (<profile>)' / 'actual
  (<snapshot name>)
- snapshot-vs-snapshot compare keeps verbatim master/check names;
  the mismatch label column widens to w-48 for prefixed labels
- tests: profilerLabelText, prefixed report/diff labels, profilerCardIccid
  decode/fallback, both check flows pass the labels; help EN/RU, READMEs
  updated; SW cache v128 -> v129.
2026-09-13 11:14:19 +03:00
catarrh fb6f80ef1d ui: translate the RAM operations hint
The data-l10n key for 'RAM operations perform atomic GlobalPlatform
commands over SCP80. Card keys are taken from the saved preset.' was
missing from LANG_RU, so the hint stayed English. SW cache v127 -> v128.
2026-09-12 23:25:42 +03:00
catarrh 2494e04984 ui: clear stale RAM status on op change, remember the card preset
- ramOpChanged() now clears the previous execution status (result line,
  steps, explorer, progress) only when the Operation value actually
  changes, so switching Explore -> Install Package no longer shows the
  old 'Partial - ELF Modules: no data' until Execute; re-entering the
  RAM subtab still preserves the last result
- ramClearResults() also hides the progress caption
- the Card preset dropdown no longer resets to the placeholder after
  every executed operation (ramSaveCntr -> ramRender rebuilt it) or on
  subtab re-entry: ramRender keeps the current/remembered index,
  ramApplyCard and ramExecute remember the selection for the session,
  and cardsRemove keeps _ramCardIdx aligned via ramCardIdxAfterRemove
- tests: op-change clearing, selection preservation across rebuilds,
  pick/execute remembering, index bookkeeping; SW cache v126 -> v127.
2026-09-12 23:20:26 +03:00
catarrh b49e6728b4 ui: translate SCP80/RAM explorer labels, buttons and result strings
The RAM explorer HTML is generated after load, so its data-l10n sections
and Delete/Delete All buttons stayed English until a language switch, and
the memory/AID/lifecycle labels were hardcoded with no key at all.

- ramRenderExploreHtml() now builds every label and button with t()
  (reusing the existing Delete/Delete All/heading/AID keys and wiring the
  three previously-unused Application/Instance, Load File and Module AID
  keys); new RU keys cover Applications:, Free NV:, Free Volatile:, AID:,
  Lifecycle:, Privileges:, SD AID:, Implicit sel:, Version: and (none)
- the explorer dataset is cached in _ramExplorerData and re-rendered by
  refreshDynamicI18n() on language switch (cleared on op change/results
  reset)
- RAM flow strings localized too: Partial/OK summaries, error labels
  (Memory/ISD/Apps/ELFs/ELF Modules/no data), GET DATA progress, delete
  confirm/labels, install alerts/progress/steps and Error:
- tests: ram.test.js asserts every explorer label/button goes through t()
  and the (none) placeholder; SW cache v125 -> v126.
2026-09-12 23:07:28 +03:00
catarrh dfb9551eb7 release: v2.1.1
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v124 -> v125.
2026-09-12 22:25:07 +03:00
catarrh 8459040856 ui: auto-refresh the proactive log and STK menu state
The proactive command list only re-rendered on tab/pill switches, so new
fetched commands (background STATUS polling, menu traffic) stayed
invisible while the Phone view was open.

- /api/status now exposes proactive_seq (_PROACTIVE_ENTRY_ID, monotonic
  and not reset with the log), so the existing 2s status poll detects
  changes with no extra request; pysimCardStateUpdate() re-renders the
  log when the sequence changed and the Phone/Phone view is visible
- the 5s backend timer no longer fetches the log (it was discarded) and
  now uses the already-fetched stk-status: when active/pending changes
  while the Phone tab is visible, stkCheckMenu() refreshes the menu
  button without a tab switch
- pysimProactiveLogRender() keeps its scroll position and only shows
  Loading... on a first/empty paint
- tests: proactive seq + STK signature helpers and poll-driven render
  behaviour in card_state.test.js; SW cache v123 -> v124.
2026-09-12 22:22:55 +03:00
catarrh a5cccee17c release: v2.1.0
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v122 -> v123.
2026-09-12 22:16:46 +03:00
catarrh 2a559371ae ui: keep the selected file in state, drop the File: title line
The File: EF.DIR [LF] line doubled as the selection store: pysimFsClickFile
wrote a formatted display string there and pysimFsRead/Save parsed it back
(stripping '✗ ' and ' [TYPE]'). The pane now keeps the name in
pysimFsSelected, the redundant title is gone (FID/type/size/FCI remain in
the info block), and the dead pysimFsSelect() plus the unused 'File:' RU
entry are removed. Read/Save still re-select on the server first: pySim's
current selection is a shared cursor that tree expansion, scans and
commands move, so it cannot identify the file shown in the pane.

SW cache v121 -> v122; structural test updated.
2026-09-12 22:12:25 +03:00
catarrh e0e86e31af ui: drop the Decoded FCI caption in the file manager info block
The decoded FCI items now follow the FID/type/size line directly; the
snapshot detail view keeps its caption. Test updated to assert the
caption is absent; SW cache v120 -> v121.
2026-09-12 21:59:41 +03:00
catarrh 2ec7a2d6c0 ui: show FID, layout and decoded FCI in the file manager detail pane
- selecting an EF now renders an info block between the File: title and
  the contents: FID, file type, size, record length/count (nulls skipped)
  followed by the decoded FCI via the existing profilerFciPreviewItems()
  decoder (no raw hex), matching the snapshot detail view
- pysimFsInfoHtml() is pure and reused by pysimFsClickFile(); the [LF]
  shorthand stays in the title; custom files get the same metadata from
  the temporary select probe
- tests: pysimFsInfoHtml in profiler.test.js (full response, missing
  fci_hex, skipped nulls) and a structural check for #pysim-fs-info;
  help EN/RU and READMEs updated; SW cache v119 -> v120.
2026-09-12 21:54:06 +03:00
catarrh 26f0063696 ui: reword the custom files hint
- EN: Custom files are added in file manager tree and included in card scan.
- RU: Пользовательские файлы добавляются в менеджер файлов и учитываются
  при чтении карт.
- SW cache v118 -> v119.
2026-09-12 21:47:23 +03:00
catarrh ddc6cb8f9d fix: parent-scoped file selection; never mutate the pySim model
pySim's lchan.select() resolves names against global selectables (self +
parent chain + MF children + applications) and falls back to probe_file(),
which blindly SELECTs an unknown FID and permanently injects a dynamically
named DF.XXXX/EF.XXXX into the running filesystem model. Probing a whole tree
or scanning a snapshot with custom files therefore polluted the model, made
tree branches show children of the wrong object, and could persist phantom
files into snapshots.

- server: new _select_with_parent()/_select_path() walk the requested parent
  path (new parent_path field, parent_sel kept as legacy fallback) strictly
  through the model and call lchan.select_file() only; model-unknown 4-hex
  segments are probed only with allow_probe and the temporary child pySim
  adds is detached again via the cleanup callable that the four handlers
  (/api/tree|select|read|write) now run in a finally block
- frontend: getParentPath() builds the segment chain (MF, ADF names, FIDs)
  and all tree/select/read/write bodies plus the snapshot/profile walker send
  parent_path; allow_probe is set only for custom files; the blind retries
  in the file manager were dropped
- tests: tests/test_select_scope.py (duplicate-FID resolution, no APDU for
  unknown non-custom files, probe+detach, model unchanged); fs_load/fs_probe
  assertions for parent_path and allow_probe; docs/api.md and AGENTS.md
  document the contract; SW cache v117 -> v118.
2026-09-12 21:45:57 +03:00
catarrh 85a66af7a5 ui: hide, don't clear, children of non-selectable DFs
- pysimFsLoadChildren failure now only marks exists=false and re-renders;
  loaded children stay in the node and are hidden by the renderer (red
  cross, no toggle, no (empty)), so a failed DF is re-attempted on the
  next probe/refresh instead of being short-circuited
- dropped the parent_sel-less /api/tree retry: error payloads now carry
  exists:false, so it doubled requests for every absent DF and re-selected
  the same FID without its parent (pySim probe_file fallback)
- probe walks strictly on exists===true and never collects files under a
  non-existing DF; no longer clears children on a failed EF select
- tests updated: single request per failed load, children untouched,
  200 exists:false marks absent without retry, render hides stale
  children, probe never fetches/selects them; SW cache v116 -> v117.
2026-09-12 21:23:31 +03:00
catarrh 92271d6726 ui: probe-all-files walk and honest file tree coloring
- pysimFsLoadChildren now treats {success:false,error} (and any error
  payload) as a failed listing: absent DFs/ADFs render as a red cross
  without an expand arrow instead of silently opening empty; the
  /api/tree 500 body also carries exists:false for shape consistency
  with /api/select
- an expanded directory with zero children shows a gray (empty)
  placeholder
- new Probe all files button (data-needs=card) in the tree header:
  walks the whole model tree from MF, verifies every DF/ADF via
  /api/tree and every file (incl. custom entries) via /api/select,
  skips subtrees of absent dirs, shows N/total progress, toggles to
  Stop, and reports present/absent counts with elapsed time; results
  colour the current tree only
- tests: fs_load (error/retry/empty), fs_render (red cross, (empty)),
  fs_probe (flags, absent-dir skip, custom files, stop, summary);
  html structural check; help EN/RU, READMEs, AGENTS updated;
  SW cache v115 -> v116.
2026-09-12 21:14:09 +03:00
catarrh 4598a70db2 ui: edit/delete buttons for custom files
- each row now shows Edit and Delete buttons (same styling as the
  profiler list buttons) instead of the red X glyph
- Edit reloads the entry into the top form: the Add button becomes
  Save (data-l10n updated too) and a Cancel button appears; submit
  updates the entry in place, recomputing fid/parentFid and excluding
  the edited row from the duplicate-path check
- Delete removes without confirmation; deleting the row being edited
  cancels the edit and an earlier deletion shifts the edit index
- pysimCustomAdd renamed to pysimCustomSubmit; tests for the whole
  flow; SW cache v114 -> v115.
2026-09-12 20:14:24 +03:00
catarrh 82fb2c8800 ui: move the Profiler tab next to SCP80
- top-level tab order is now Remote APDU, SCP80, Profiler, Card reader,
  Phone simulator; the profiler view moved with its button so the DOM
  order matches the tab strip
- docs (help EN/RU, READMEs) and the tab-order test updated; SW cache
  v113 -> v114.
2026-09-12 20:09:47 +03:00
catarrh f16e24ba11 ui: enlarge the header sim/nosim icon to 30px
- the SVG artwork occupies only ~46% x 63% of its 183x183 canvas, so
  the 18px render showed ~8x11px of ink; 30px roughly doubles the
  visible glyph while staying under the 32px h1 line-height, so the
  header row height is unchanged
- guard test asserts the inline size stays within the 24-32px budget;
  SW cache v112 -> v113.
2026-09-12 20:03:03 +03:00
catarrh 8b814eb2f8 ui: status indicator no longer uses the text cursor
- the wrapper is select-none with an inline cursor:default (the compiled
  CSS has no cursor-default utility), so hovering the dot/icons no
  longer shows the I-beam or allows text selection
- SW cache v111 -> v112.
2026-09-12 19:57:05 +03:00
catarrh 56c0e60462 ui: tooltip on the status dot shows the server state
- the dot carries its own title (Connecting... / No server connection),
  matching the wrapper tooltip; it is cleared when the sim icons replace
  the dot
- tests assert the dot title in both server states and its removal once
  the server is up; SW cache v110 -> v111.
2026-09-12 19:55:14 +03:00
catarrh 68aa37f093 ui: header indicator shows sim/nosim icons once the server is up
- #state-indicator is now a wrapper with a dot plus an 18px dark:invert
  image; no new compiled Tailwind classes (h-4/w-5 are not in the
  prebuilt CSS), so sizing uses an inline style and the header height
  is unchanged
- gray dot while probing, red dot when the server is unreachable;
  server up -> nosim.svg, animated sim_anim.svg while equipping, and
  sim.svg when the card is equipped (`_pysimEquipping` tracked from
  status.equipping, refreshed on every 2s poll)
- i18n reuses existing strings; tests for all five states, dot color
  transitions and the markup; SW cache v109 -> v110.
2026-09-12 19:53:29 +03:00
catarrh 5485d61390 ui: sort the file manager tree by FID or symbolic name
- pysimFsSortChildren() groups DFs above EFs, then sorts each group by
  FID (default) or case-insensitive symbolic name; missing names fall
  back to the FID as the sort key; deterministic tie-break; the input
  array is not mutated
- render uses the sorted copy, so lazily loaded directories and custom
  file injections are sorted too
- FID / Name pills above the tree (pysimFsSetSort) persist the choice in
  localStorage (otaman_fs_sort) and re-render without refetching
- tests for the comparator (DF priority, both keys, fallbacks, custom
  entries, no mutation) and the pill wiring; help/README updated;
  SW cache v108 -> v109.
2026-09-12 19:41:49 +03:00
catarrh 45c72d874f stk: never shadow a paused command with a new menu selection
The stuck-card pattern: after a Back TR the card re-issues the parent menu
as a new SELECT ITEM (91XX -> FETCH, paused, awaiting TR), but the UI's
back/timeout branch ignored that response and showed the cached top menu;
the next item click then sent ENVELOPE(Menu Selection) while the card was
waiting for the TR. The card answers such an ENVELOPE with 9000 (not the
usual 91XX), and menu-select cleared the pending command without a TR,
leaving an unfinished proactive session until reset/equip.

- server: _finish_pending_menu() answers a paused command with a cancel TR
  (0x10) before /api/menu-select sends its ENVELOPE and drains a 91XX
  follow-up, so a new selection can never shadow an unanswered FETCH
- frontend: stkMenuRespond('back'|'timeout') renders the follow-up command
  from the server response (SELECT ITEM / DISPLAY TEXT) and shows the
  cached top menu only when the TR answer carries no command (9000)
- tests: finish-pending cancel TR + chain drain (Python); stkMenuRespond
  back/timeout/cancel/ok rendering (frontend); help/AGENTS updated;
  SW cache v107 -> v108.
2026-09-12 19:34:27 +03:00
catarrh 1fe5c6347d ui: do not show 'initializing' when no card is inserted
The message branch treated the static auto_equip config flag as if
initialization were in progress, so with auto-equip on (default) a
cardless server showed 'Card inserted — initializing...' instead of the
no-card message. The initializing state now requires equipping or an
actually present card with auto-equip enabled; regression test added.
SW cache v106 -> v107.
2026-09-12 18:09:06 +03:00
catarrh c4ed064318 ui: center second-level pill rows on every page
SCP80, Card reader and Profiler list pill rows now use the same centered
layout as Remote APDU and Phone simulator (flex-wrap justify-center).
SW cache v105 -> v106.
2026-09-12 18:05:58 +03:00
catarrh 2a43424ae6 ui: use pills for the Profiler list tabs
Profiles / Card snapshots / Custom files switch from top-bar style tabs
(border-b, rounded-t) to the rounded-full pill style used by the other
second-level switchers. SW cache v104 -> v105.
2026-09-12 18:03:37 +03:00
catarrh 5b9e821d6d ui: gate controls by server/card state; move Custom files to Profiler
Availability model: server-down / server-up-no-card / card-equipped.
- header shows a symbolic indicator dot (red/yellow/green, tooltip)
- a 5s probe tracks /api/version+/api/status while not fully ready; the
  2s status poll takes over once connected
- controls declare data-needs='server' or 'card'; disabled + tooltip when
  the state does not satisfy them: equip (server), status/reset, file
  manager read/save/edit, raw APDU, profile-from-card, new snapshot,
  profiler list Check card, event Send, RAM install/explore delete,
  STK menu, Send STATUS, Verify vs pySim (server), pySim execute (server),
  PLI save/poll toggle (server)
- pysimConnect no longer conflates server and card: it records both and
  Connect stays usable without a card
- ramSendOta/ramInstallCap now use pysimFetch instead of raw fetch
  (relative /api URLs broke custom server URLs)

Custom files moved from the Card reader sub-tabs to the Profiler list
tabs (Profiles / Card snapshots / Custom files); help/README/AGENTS
updated. Tests: availability gating, indicator structure, moved tab.
SW cache v103 -> v104.
2026-09-12 17:58:30 +03:00
38 changed files with 10784 additions and 735 deletions
+1
View File
@@ -0,0 +1 @@
*.html linguist-detectable=false
+36 -16
View File
@@ -31,7 +31,7 @@ npm run build
## Interface
Five top-level tabs: **Remote APDU**, **SCP80**, **Card reader**, **Profiler**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has four sub-tabs: **File manager**, **Custom files**, **pySim command line**, and **Raw APDU**.
Six top-level tabs: **Remote APDU**, **SCP80**, **SCP81**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
---
@@ -486,20 +486,13 @@ Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live c
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Click to read contents.
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills above the tree, remembered in `localStorage`)
- **Read** — reads the selected file (auto-detects transparent vs record files)
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
### Custom Files
Files not in pysim's model can be added manually:
1. Switch to the **Custom files** sub-tab
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
3. Click **Add** — the file appears in the tree in italics (unverified)
4. Click the file to verify existence — on success, it behaves like a model file
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
- Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents
- Missing files are shown in red (✗); a present but empty DF shows `(empty)`
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
### Command Hints
@@ -511,7 +504,7 @@ Type a command name in the **pySim command line** input. Usage hints appear as a
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Import profile** loads a ruleset from JSON (the name is stored inside the file).
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Profile from snapshot** generates the same ruleset from a saved snapshot (same ignore/mask/FCP-FCI options, no card reader, name prefilled from the snapshot); **Import profile** loads a ruleset from JSON (the name is stored inside the file).
- Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Export**, and **Delete**.
A filesystem rule is defined by:
@@ -521,7 +514,7 @@ A filesystem rule is defined by:
- **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset).
- **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list.
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: <profile> → <card ICCID>` (or `… → <snapshot name>`; snapshot comparison: `Snapshot comparison results: <master> → <checked>`). **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
#### “Profile from card” scan options
@@ -540,6 +533,19 @@ The list view has two tabs — **Profiles** and **Card snapshots**. A snapshot i
---
#### Custom files
Files not in pysim's model can be added manually:
1. Switch to the **Custom files** tab in the Profiler list
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
3. Click **Add** — the file appears in the tree in italics (unverified)
4. Use **Edit** on a row to reload it into the form (the button becomes **Save** and a **Cancel** button appears) or **Delete** to remove it
5. Click the file to verify existence — on success, it behaves like a model file
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
## Phone simulator
The **Phone simulator** tab provides real-time CAT session interaction. It has two pills: **Phone** (STK menu, STATUS and polling, subscribed events, proactive command log) and **TR Config** (response data injected into TERMINAL RESPONSEs for proactive commands).
@@ -550,12 +556,16 @@ The **Phone simulator** tab provides real-time CAT session interaction. It has t
- **Location Status** — dropdown for Normal / Limited / No service
- **Access Technology Change** — dropdown for all 13 RAT types
- **Card Reader Status, Language, UICC Access** — appropriate inputs
- **Channel Status** — channel selector, link state (not established / TCP
LISTEN / established) and info (no further info / link dropped), per TS 102 223 8.56
- **Network Rejection** — full adaptive form with registration type dropdown
(LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access
technology selection, and 53-cause unified rejection cause code dropdown
covering EMM, GMM, 5GMM, and LU causes
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, and PROVIDE LOCAL INFORMATION.
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT, and the BIP commands (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP commands are decoded with both plain and comprehension-required TLV tags.
**Timer management** — the server acts as the terminal for TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): started timers are tracked per card session, deactivate/get TERMINAL RESPONSEs carry the remaining value, and on expiry the card receives ENVELOPE (TIMER EXPIRATION). The live card uses this to retry the OTA session after a failed OPEN CHANNEL.
**TR Config: PLI data dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
@@ -574,6 +584,16 @@ The **Phone simulator** tab provides real-time CAT session interaction. It has t
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
## SCP81
The **SCP81** tab drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B). The card's BIP channel is always redirected to a local listener on the server:
- **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering. Use it to inspect what the card asks for.
- **PSK TLS server** — answers the handshake with the TLS 1.2 PSK cipher suites of the spec and speaks the GP HTTP administration dialog (`X-Admin-*` headers, `200` with a command string or `204 No Content`). Enter the **PSK Identity** the card uses and the **PSK key (hex)**; the key is only sent to the local server, never stored or logged.
- **Script** — the command script served over the session: **Memory + ELF info** (default) sends `GET DATA FF21` (available non-volatile/volatile memory, applet count) and `GET STATUS P1=20/10` (Executable Load Files and modules registry) as RAM/GP commands in TS 102 226 Command Scripting templates, one C-APDU per request; **None** closes every session with `204`. Custom APDU lists are accepted by the API.
The state line shows the listener, the negotiated identity and live channels (bytes in/out); the log records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step, including each R-APDU (`script-rapdu`, `script-memory`). The same controls are available through `POST /api/scp81/bip` and `GET /api/scp81/script` (see `docs/api.md`).
## PWA
OTAMan is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt.
@@ -631,7 +651,7 @@ pysim-otaman-server --http-port 8080
| `--apdu-trace` | Log APDU-level traces to stderr |
| `--log-requests` | Log request/response payloads to stderr |
| `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit |
| `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) |
| `--terminal-profile` | TERMINAL PROFILE payload hex (default: 33-byte real-handset profile that advertises BIP events/commands; the live card ignores HTTP OTA without it) |
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s; `0` disables polling) |
| `--full-pysim-init` | Use pysim's stock init/equip (redundant card resets). The default init/equip is reset-free — only explicit equip/reset reconnect the card |
| `--no-auto-equip` | Do not initialize a card automatically right after it is inserted (default: auto-equip on) |
+35 -15
View File
@@ -31,7 +31,7 @@ npm run build
## Интерфейс
Пять вкладок: **Remote APDU**, **SCP80**, **Card reader**, **Profiler** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader четыре подвкладки: **File manager**, **Custom files**, **pySim command line** и **Raw APDU**.
Шесть вкладок: **Remote APDU**, **SCP80**, **SCP81**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
---
@@ -460,20 +460,13 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы над деревом, выбор сохраняется в `localStorage`)
- **Read** — чтение файла (автоопределение transparent/record)
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
### Пользовательские файлы
Файлы, отсутствующие в модели pysim, можно добавить вручную:
1. Перейдите на вкладку **Custom files**
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
4. Кликните для проверки существования — при успехе работает как обычный файл
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
### Подсказки команд
@@ -485,7 +478,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Профиль из снимка** создаёт тот же набор правил из сохранённого снимка (те же опции игнорирования/масок/FCP-FCI, без картридера, имя подставляется из снимка); **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**.
Правило файловой системы задаётся:
@@ -495,7 +488,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → <ICCID карты>` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
#### Опции сканирования «Профиль с карты»
@@ -514,6 +507,19 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
---
#### Пользовательские файлы
Файлы, отсутствующие в модели pysim, можно добавить вручную:
1. Перейдите на вкладку **Custom files** в списке профайлера
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
4. Кнопка **Edit** загружает запись в форму (кнопка становится **Save**, появляется **Cancel**), **Delete** удаляет запись без подтверждения
5. Кликните для проверки существования — при успехе работает как обычный файл
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
## Симулятор телефона
Вкладка **«Симулятор телефона»** обеспечивает взаимодействие с CAT-сессией в реальном времени. Две подвкладки: **«Телефон»** (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и **«Конфигурация TR»** (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).
@@ -523,9 +529,13 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- **События без данных** (User Activity, Idle Screen и др.) — однократное уведомление
- **Location Status** — выпадающий список: Normal / Limited / No service
- **Access Technology Change** — 13 типов RAT
- **Channel Status** — выбор канала, состояние линии (не установлена / TCP
LISTEN / установлена) и информация (нет данных / линия разорвана), TS 102 223 8.56
- **Network Rejection** — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля локации (MCC, MNC, LAC, RAC, TAC), доступные технологии, 53-позиционный выпадающий список причин отказа (EMM, GMM, 5GMM, LU)
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор. Поддерживаются SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT и BIP-команды (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP-команды декодируются с обычными и comprehension-required TLV-тегами.
**Управление таймерами** — сервер выполняет роль терминала для TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): запущенные картой таймеры отслеживаются в рамках сессии, TERMINAL RESPONSE на deactivate/get содержит остаток, а по истечении карта получает ENVELOPE (TIMER EXPIRATION). Живая карта использует это для повторения OTA-сессии после неудачного OPEN CHANNEL.
**Конфигурация TR: словарь PLI** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
@@ -544,6 +554,16 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
## SCP81
Вкладка **SCP81** управляет HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B). BIP-канал карты всегда перенаправляется на локальный слушатель сервера:
- **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая. Удобно для изучения запросов карты.
- **PSK TLS server** — отвечает на рукопожатие PSK-наборами TLS 1.2 из спецификации и ведёт HTTP-диалог административной сессии GP (заголовки `X-Admin-*`, `200` со строкой команд или `204 No Content`). Укажите **PSK Identity**, которую использует карта, и **PSK ключ (hex)**; ключ передаётся только локальному серверу, не сохраняется и не записывается в журнал.
- **Script** — сценарий команд, отдаваемых в сессии: **Память + ELF** (по умолчанию) отправляет `GET DATA FF21` (доступная энергонезависимая/энергозависимая память, число апплетов) и `GET STATUS P1=20/10` (реестр Executable Load File и модулей) как RAM/GP-команды в Command Scripting template по TS 102 226, по одной C-APDU на запрос; **None** закрывает каждую сессию ответом `204`. Свой список APDU можно задать через API.
Строка состояния показывает слушатель, согласованную identity и активные каналы (байты in/out); журнал фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP. Те же функции доступны через `POST /api/scp81/bip` (см. `docs/api.md`).
## PWA
OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
+246 -8
View File
@@ -41,11 +41,21 @@ connect and warns if versions are incompatible.
| `/api/proactive-log` | GET | Last 50 proactive commands |
| `/api/status-poll` | POST | Manual STATUS poll + FETCH if 91XX |
| `/api/rescue` | POST | Re-send TERMINAL PROFILE to recover CAT session |
| `/api/terminal-profile` | GET | Current TERMINAL PROFILE (hex) + CLI default |
| `/api/terminal-profile` | POST | Set and re-send the TERMINAL PROFILE at runtime (in-memory) |
| `/api/poll-status` | GET | Background STATUS polling state |
| `/api/poll-toggle` | POST | Enable/disable background polling |
| `/api/pli-qualifiers` | GET | List of qualifier codes with descriptions |
| `/api/pli-dict` | GET | Current dictionary (hex values per qualifier) |
| `/api/pli-dict` | POST | Update dictionary entries |
| `/api/scp81/bip` | POST | Start/stop the HTTP OTA listener (dump capture or PSK TLS server) |
| `/api/scp81/status` | GET | BIP terminal + listener state (channels, PSK identities, handshake identity) |
| `/api/scp81/log` | GET | HTTP OTA event log (`?after=<seq>`) |
| `/api/scp81/log-clear` | POST | Clear the HTTP OTA event log |
| `/api/scp81/queue` | POST | Replace the SCP81 command script (optionally force-restart) |
| `/api/scp81/script` | GET | Active command script + execution state and R-APDUs |
| `/api/scp81/psk-map` | POST | Replace the PSK table of a running TLS listener |
| `/api/scp81/gen-install` | POST | Generate the RAM APDU list for a `.cap` (no queueing) |
## Endpoint details
@@ -55,7 +65,7 @@ Returns server version for compatibility checking.
**Example response:**
```json
{"version": "2.0.0"}
{"version": "2.1.2"}
```
### `GET /api/status`
@@ -172,6 +182,7 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params |
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` |
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
| `load_block_size` | no | Bytes of load-file payload per LOAD APDU, 1240. When empty/omitted the server auto-fits: the largest size whose SCP80 secured packet still encodes into one SMS (140 octets; e.g. 107 for the 3DES `spi1=16/spi2=01` configuration). An explicit value larger than the fitting size is clamped; over SCP80 the default 240 does **not** fit and used to fail with pySim's "Cannot encode command in a single SMS". |
**Response (success):**
```json
@@ -182,9 +193,17 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
"final_cntr": "0000000004",
"load_file_aid": "A000000003000000",
"module_aid": "A000000003000000",
"application_aid": "A000000003000000"}
"application_aid": "A000000003000000",
"load_block_size": 107,
"load_block_size_requested": null,
"load_block_size_clamped": false}
```
`load_block_size` is the effective size used for the LOAD blocks,
`load_block_size_requested` echoes an explicit `load_block_size` (null =
auto-fit) and `load_block_size_clamped` is true when the requested size was
reduced to fit one SMS.
**Response (failure):**
```json
{"success": false, "failed_step": "load_1",
@@ -272,7 +291,7 @@ Returns the current STK session state.
Read file content. Auto-detects transparent vs record files.
```json
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"}
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
```
Returns transparent data:
@@ -298,12 +317,12 @@ it for snapshot timing statistics. Other commands are not reported.
Write raw hex data to a file.
```json
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"}
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]}
```
For record files:
```json
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_sel": "7F10"}
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
```
Returns:
@@ -316,9 +335,19 @@ Returns:
Select a file by name or FID, with optional parent selection.
```json
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00"}
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"]}
```
`parent_path` lists the path segments from MF to the parent (ADF names or
FIDs); the legacy single-segment `parent_sel` is still accepted but is only
unambiguous for ADFs. Resolution is strictly parent-scoped: model-known files
are selected through the requested parent only (pySim `select_file()`), never
via pySim's global selectables or its `probe_file()` model injection, so a
same-FID file under another parent is never picked and the filesystem model
is not modified. `allow_probe: true` (PWA custom files) additionally allows a
model-unknown 4-hex FID to be selected directly; any temporary model object
created for it is detached again before the response is sent.
Returns:
```json
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
@@ -340,6 +369,9 @@ Get directory listing with typed children.
{"name": "MF", "fid": "3F00"}
```
Use `parent_path` (or the legacy `parent_sel`) to list a subdirectory, e.g.
`{"name": "DF.GSM-ACCESS", "fid": "5F3B", "parent_path": ["MF", "ADF.USIM"]}`.
Returns:
```json
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
@@ -365,6 +397,13 @@ optional hex for events that carry data. Returns the SW and any response data:
{"sw": "9000", "data": "..."}
```
Channel status (event `0x0A`, TS 102 223 §8.56) carries the Channel status TLV
`B8 02 <status> <info>`, where the status byte is the channel id (17) OR-ed
with the state bits (0x00 link not established / 0x40 TCP LISTEN / 0x80 link
established) and the info byte is `00` (no further info) or `05` (link
dropped). The server also sends this event automatically when a BIP link drops
outside a proactive command and the card subscribed to `0x0A`.
### `GET /api/proactive-log`
Returns the last 50 proactive commands fetched during CAT sessions, newest
@@ -386,12 +425,37 @@ proactive chain (FETCH → TERMINAL RESPONSE) until it settles. Returns:
### `POST /api/rescue`
Recovers a stuck CAT session by clearing the pending state and re-sending the
TERMINAL PROFILE. Returns whether a menu and event list were captured again:
TERMINAL PROFILE. Returns whether a menu and event list were captured again
(plus the profile used):
```json
{"menu": true, "events": [4, 5]}
{"ok": true, "profile": "FFFF...", "menu": true, "events": [4, 5]}
```
### `GET /api/terminal-profile`
The TERMINAL PROFILE currently in effect and the CLI default (for reference;
runtime changes are in-memory only):
```json
{"profile": "FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003",
"bytes": 33,
"cli_default": "FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003"}
```
### `POST /api/terminal-profile`
Sets the TERMINAL PROFILE at runtime (in-memory) and re-sends it to the card,
resetting the STK session state exactly like `/api/rescue`. Body with a new
profile, or `{}` to re-send the current one:
```json
{"profile": "FFFFFFFF7F1F007FFF00001F230811060700"}
```
Hex, even number of digits, 1255 bytes. Response is the same shape as
`/api/rescue` (with `ok: true`); invalid hex is a 400, no reader a 503.
### `GET /api/poll-status`
Background STATUS polling state.
@@ -431,3 +495,177 @@ Returns the current PLI data dictionary as a qualifier-code map.
Updates dictionary entries. Body is a map of qualifier code to hex value; keys
must be known qualifiers and values valid hex, otherwise they are ignored.
Returns the updated dictionary.
### `POST /api/scp81/bip`
Starts or stops the local target the card's BIP channel is redirected to.
Dump mode captures whatever the card sends (e.g. its TLS ClientHello)
without answering:
```json
{"action": "start", "mode": "dump", "host": "127.0.0.1", "port": 8443}
```
Pass-through mode (`mode: "passthru"`) starts **no local listener**: every BIP
channel the card opens is connected to the configured external platform
(`host`/`port` are required — no defaults), which terminates TLS and runs the
administration dialog; the address the card requests is only logged. The
status API reports `mode: "passthru"` with the target while it runs.
```json
{"action": "start", "mode": "passthru", "host": "203.0.113.10", "port": 10174}
```
TLS mode runs the Phase B PSK TLS server (GPC v2.2 Amendment B): the PSK
table is applied to the TLS handshake, and the GP HTTP administration dialog
(`X-Admin-*` headers, 200 with a command string or 204 No Content) is served.
`psk_map` is the lookup table for the identity the card presents in the TLS
handshake — the PWA sends it from the card presets (`{identity, psk_hex}`
objects or an `{identity: psk_hex}` map); a handshake whose identity is not
listed fails with the log entry `tls-psk-unknown`. The legacy single-key form
`psk_hex` (with optional `psk_identity`, empty = accept any identity) is still
accepted; when both are omitted the table of the previous start is reused.
Keys are never stored or logged.
```json
{"action": "start", "mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_map": [{"identity": "89012345678901234567",
"psk_hex": "00112233445566778899aabbccddeeff"}],
"script": ["80CAFF2100", "80F28002024F0000"], "script_kind": "Explore"}
```
`script` is the APDU list served to the card (an explicit list, or `none`);
the server is agnostic to what the APDUs do. `script_kind` is an optional
label for the logs/results. Omitting `script` keeps the configured script and
its run progress.
Stop either mode with `{"action": "stop"}` (also disables the BIP terminal).
### `GET /api/scp81/status`
```json
{"bip": {"enabled": true, "target": "127.0.0.1:8443", "channels": [], "seq": 12},
"listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_identities": ["89012345678901234567"], "psk_wildcard": false,
"identity_seen": "89012345678901234567", "identity_matched": true}}
```
Listener modes: `tls` (local PSK TLS server), `dump` (capture-only TCP
listener) and `passthru` (no local listener; the BIP channels go straight to
`host:port`, e.g. an external HTTP OTA platform — reported as
`{"mode": "passthru", "host": ..., "port": ..., "target": "host:port"}`).
`psk_identities` lists the identities the listener accepts (keys are never
exposed); `psk_wildcard` marks the legacy single-key mode. `identity_seen` /
`identity_matched` reflect the last handshake: an unknown identity is logged
as `tls-psk-unknown` and the handshake fails.
### `POST /api/scp81/psk-map`
Replaces the PSK table of the running TLS listener (the PWA pushes card-preset
edits without a listener restart):
```json
{"psk_map": [{"identity": "89012345678901234567",
"psk_hex": "00112233445566778899aabbccddeeff"}]}
```
Returns `{"ok": true, "identities": [...], "listener": {...}}`; entries
without an identity or a valid key are skipped, and an empty table is
rejected.
### `GET /api/scp81/log`
Returns the BIP/TLS event log (open/close, SEND/RECEIVE DATA hex, TLS
handshake and HTTP request/response records). `?after=<seq>` returns only
newer entries; `seq` echoes the latest sequence number.
### `POST /api/scp81/queue`
Replace the SCP81 command script (used by the Remote APDU tab's RAM chain
"Queue in SCP81" and the PWA's "Restart script"). Body
`{"apdus": ["80E60C002E...", ...]}` (or a single `apdu`), optional `kind` and
`force`. Entries that already are Command Scripting templates
(`AA...`/`AE80...`, the expanded format) are sent verbatim instead of being
wrapped again. Refused while a script is mid-run unless forced; queuing resets
the execution progress.
### `POST /api/scp81/gen-install`
Generate the RAM (GP) APDU sequence for a `.cap` without touching the listener
or the running script; the PWA's "Install from .cap" script template stores
the returned list. The `.cap` is parsed server-side (same parser as
`/api/ram-install`) and expanded to INSTALL [for load] -> LOAD blocks
(240-byte payloads) -> INSTALL [for install]; the file itself is never stored.
```json
{"cap_hex": "504B0304...", "sd_aid": "A000000003000000", "privileges": "00",
"install_params": "", "stk_params": "", "make_selectable": true}
```
`sd_aid` empty = the ISD. Responds with `{"ok": true, "apdus": [...],
"load_file_aid": ..., "module_aid": ...}`.
### `GET /api/scp81/script`
Returns the configured command script and the execution state:
```json
{"script": ["80CAFF2100", "80F28002024F0000"], "next": 2, "total": 2,
"done": [0, 1], "kind": "Explore",
"pending": {"index": 17, "pos": null, "page": true, "apdu": "80F28003024F0000"},
"pages": 11, "pages_queued": 0, "complete": false,
"results": [{"index": 1, "pos": 0, "page": false, "sw": "9000",
"apdu": "80CAFF2100", "rapdu": "FF210C810102..."}]}
```
`next` is the index of the next script APDU to send; `done` lists the script
indices the card reported. `pending` describes the C-APDU awaiting the card's
`X-Admin-Script-Status` report as `{index, pos, page, apdu}` (`pos` = script
index, `null` for an auto continuation page) or `null`; `pages` counts the
continuation pages queued so far and `pages_queued` those not yet sent.
`complete` is true when every configured APDU was reported and nothing is in
flight — a script can therefore be complete while a listing page is still
being fetched (`pending.page` = true), which is tracked separately from the
script's own progress. `results` entries carry the send order (`index`), the
script position (`pos`, `null` for continuation pages) and the `page` flag.
Execution tracking and resume: an APDU counts as executed only when the card
reports it in the next POST's Response Scripting template. A POST with
`X-Admin-Resume` continues with the unexecuted tail (the pending APDU is
resent if its report never arrived), a POST without it is a fresh dialog where
the script runs from the start, and a completed script closes the session with
204.
Each APDU is
delivered in an `AE 80 22 <len> <apdu> 00 00` Command Scripting template
(TS 102 226 §5.2.1) with `X-Admin-Next-URI`; the card returns its R-APDUs in
the next POST's Response Scripting template, which is parsed and logged
(`script-rapdu`, `script-memory`). Long GET STATUS listings that answer
`63 10` / `CA FE` ("more data available") are auto-continued with the same
command carrying P2.b1=1.
TLS mode also accepts `chunked` (**default `true`** — the reference server's
chunked framing; the card rejects a chunked response that also carries a
Content-Length) and `chunk_size` (default `0` — the whole response in one TLS
record, as in the decrypted reference session; a positive value writes the
head and each body piece as its own record). Both are echoed by
`GET /api/scp81/status`.
`keep_alive` (default `true`, matching the reference session: the card sends
all its POSTs on one connection until the 204) ends the TLS connection after
each response
(after the card drained the BIP buffer, with `close_notify`, so the card
processes the script and opens a new connection for its next POST);
`compact_headers` (default `false`) drops the space after each header colon,
`apache_headers` (default `true`) adds Date/Server/X-Powered-By like the
reference servers and puts Transfer-Encoding before Content-Type,
`conn_header` (default `'none'` = omit the header, like the reference)
declares the connection fate, `tls_version` pins `1.1`/`1.0` for cards that
only speak the older record layer, `cipher` pins one suite, `next_uri`
overrides the per-command `X-Admin-Next-URI` (`%d` = command id; empty string
omits the header), `link_events` (default `true`) controls the automatic
Channel status events, `answer_delay` waits before answering a request. `keylog` writes the TLS traffic secrets to
the given file (SSLKEYLOGFILE format) for debugging captures — it contains key
material, use a temporary path.
+323
View File
@@ -0,0 +1,323 @@
# SCP81 / HTTP OTA live-card findings
Living debug log for the HTTP OTA (RAM over HTTP) work against the live UICC.
Purpose: record **every attempted configuration and its outcome**, so the same
variations are not repeated. Add rows as tests are run; keep the confirmed
rules section current.
Setup: `pysim_otaman_server` with a PC/SC reader, the PWA SCP81 tab (or
`POST /api/scp81/bip`), the card triggered by its SMS-PP push / the Location
status event. Server log at `GET /api/scp81/log`, script state at
`GET /api/scp81/script`, proactive history at `GET /api/proactive-log`.
## RESOLVED 2026-09-16: the card never received the response - BIP TLV bug
**Root cause:** our RECEIVE DATA TERMINAL RESPONSE encoded the channel-data
TLV length as a raw byte (`36 ED ...` for a 237-byte chunk). BER requires the
long form for lengths >127: **`36 81 ED ...`** (the reference terminal traces
use exactly that, e.g. `push_3311_success_req2.pcapng`). The card's BIP layer
silently mis-parsed the malformed TLV, so the TLS record bytes never reached
its TLS stack: no alert, no script processing, and the SD kept resuming its
dialog ("no complete script received") forever. Every delivery <=127 bytes
(handshake records, 204 responses) always worked - which is why the handshake
succeeded and only the large script responses "vanished".
**Fix:** `_handle_bip_command` (cmd 0x42) BER-encodes the channel data length
(`36 81 <len>` above 127); regression test
`test_receive_data_tlv_long_form_length`.
**Result with the live card** (one push, `explore` script, 5/5 commands):
```
#1 80CAFF2100 SW 9000 FF210B 81010D 8202C5D6 83020962 (13 applets,
free NV 50646 B, free volatile 2402 B)
#2 80F28002024F0000 SW 9000 ISD A000000003000000 + D276000005AAFFCAFE00
#3 80CA008500 SW 9000 stored HTTP OTA parameters
#4 80F24002024F0000 SW CAFE 127-byte ELF registry page (more available)
#5 80F21002024F0000 SW CAFE 127-byte applications page (more available)
```
Every command returned `X-Admin-Script-Status: ok` on the card's own POST to
the incremented `X-Admin-Next-URI`, on the same keep-alive connection, and the
session ended with 204 + mutual close_notify - exactly the reference flow.
`SW CAFE` marks a truncated 127-byte page: the remaining entries need a
continuation GET STATUS (P2=02 with the last AID as search criterion).
## Live card facts (verified via the reader, 2026-09-16)
- `80CAFF2100` (GET DATA extended card resources) **works**:
`FF21 0B 81 01 0D 82 02 C5 D6 83 02 09 62` -> 13 applets installed,
free NV memory `0xC5D6` = 50646 B, free volatile `0x0962` = 2402 B.
- `80CA008500` (GET DATA HTTP administration parameters) **works** and returns
the SD's stored OTA configuration: `8A 09 "localhost"`, `8B 14 <agent id>`,
`8C 01 "/"` (stored URI), `85 14 <PSK identity>`, `86 07 00 01 25 03 00 10 00`
(retry counter 1, timer **10 minutes**), `02 40 01` (KVN/KID), APN-ish
`C7 04 03 47 50 42`, destination `BE 05 21 5B D5 05 02` = 91.213.5.2.
- `80F28002/80F24002/80F21002 ...4F0000` return `6985` through the reader when
the ISD is not the current DF; the reference platform sends
`80F28002024F0000` over HTTP, where the SD executes inside the ISD.
- `SELECT` of the ISD (`00A4040008A000000003000000`) returns `6112`;
a subsequent GET RESPONSE (`00C0000012`) returns `6D00`.
- BIP device identities: OPEN CHANNEL uses destination `0x82`; SEND/RECEIVE
DATA carry channel `0x21..0x27` (e.g. `82 02 81 22` = channel 2).
- Subscribed events (`99 03`): `03` location status, `09` data available,
`0A` channel status.
- A Location status event re-triggers the OTA session only while the last
session is incomplete; after a clean session end the card waits for a push.
- The SD stores a 10-minute retry timer (`25 03 00 10 00`).
## Confirmed rules (with evidence)
1. **The card needs a clean TLS close, with the close_notify actually
fetched.** Keep-alive (no close) -> fatal `unexpected_message` after it
fetched the response. `close_notify` sent *after* the buffer drained is
never fetched (the card ends the dialog on its own first). Correct order:
send it while the response still waits, then wait for the drain, then
close.
2. **The card's abort alert is `fatal unexpected_message`** - decrypted with
the listener's `keylog` option (see `tools/scp81_decrypt.py`).
3. **A dropped link must be signalled (TS 102 223 7.5.11), and only after the
buffered data was fetched.** Signalling the drop while bytes are still in
the BIP buffer makes the card abort the fetch mid-record and end the
session. Omitting the signal entirely hangs the SD: after a listener
restart dropped the channel silently, the card ignored pushes and location
events for minutes; a manual `ENVELOPE (Channel status, B8 02 02 05)`
immediately made it start a fresh session.
4. **The Next-URI shape matters.** A path-only or absolute Next-URI (`/`,
`/1`, `http://127.0.0.1:8443/api/scp81`) draws the fatal
`unexpected_message`; the reference-style relative path **with a query**
(`/adminserver?PHPSESSID=...&apdu_id=101`) does not.
5. **The reference administration server** (`samples/HTTP_OTA/
httpota_adminserver_php_v2`) uses: command script
`AE 80 22 <len> <apdu> 00 00`; response `200` with
`X-Admin-Protocol`, `X-Admin-Next-URI: /adminserver?PHPSESSID=<id>&apdu_id=<n>`,
`Content-Type: ...;version=1.0`, **chunked** body (100-byte chunks);
the card returns the R-APDU as the body of its next POST with
`X-Admin-Script-Status: ok`; the server ends with `204`.
Its log proves the card followed the Next-URI three times within 1-2 s per
step (`Got next request ... Script status is 'ok' - storing R-APDU data`).
6. **`chunked=false` (Content-Length) has never produced an R-APDU.** All
sessions that ended silently (clean close, no alert, no POST) used
`Content-Length`. Hypothesis: the card only treats a chunked body as a
command script; with Content-Length it sees an empty script, executes
nothing and ends the session gracefully.
## The one fully successful session trace (ground truth)
`traces/HTTPOTA_session_3311_success1.pcap` (2019, **plain HTTP on port 80**,
one TCP connection for the whole session, card `3311` - *not* our UICC):
```
POST /server/adminagent?cmd=1 <- card (trigger URI, with query!)
200 OK + Date/Server + X-Admin-Protocol
+ X-Admin-Next-URI: /Download?req=1 + Content-Length: 11
+ Content-Type: .../card-content-mgt;version=1.0
body: ae 80 22 05 80 ca 00 85 00 00 00 (script: GET DATA 0085)
POST /Download?req=1 <- card, SAME connection
X-Admin-Script-Status: ok
Content-Type: .../card-content-mgt-response;version=1.0
Transfer-Encoding: chunked
body: "8
" af 80 23 02 6a 88 00 00 "0
" (R-APDU SW 6A88)
200 OK + X-Admin-Next-URI: /Download?req=2 + Content-Length: 14
body: ae 80 22 08 80 f2 80 02 02 4f 00 00 00 00 (GET STATUS P1=80)
POST /Download?req=2 -> X-Admin-Script-Status: ok, chunked
body: "1F
" af 80 23 19 <25-byte R-APDU ... 90 00> 00 00 "0
"
200 OK + /Download?req=3 + 11-byte script
POST /Download?req=3 -> status ok, R-APDU 23 02 6d 00 (SW 6D00)
204 No Content <- session ends
```
Confirmed from it: the card echoes the `X-Admin-Next-URI` (path *and* query)
verbatim; its response POST goes on the **same TCP connection**; its response
is the `AF 80 23 <len> <R-APDU> 00 00` indefinite Response Scripting template
(in a chunked body, with `X-Admin-Script-Status`); the server's script
`AE 80 22 <len> <APDU> 00 00` matches ours byte for byte; the server uses
`Content-Length` (not chunked), no `Connection` header (implicit keep-alive),
and ends with 204.
## Attempt matrix
| # | transport | framing | Next-URI | close | link events | outcome |
|---|-----------|---------|----------|-------|-------------|---------|
| 1 | dump mode only | - | - | - | off | OPEN CHANNEL + ClientHello captured (Phase A) |
| 2 | TLS, 204 only | - | - | yes | off | session completes cleanly, no alert (Phase B, live) |
| 3 | TLS + script | chunked 100 | `/N` | early (raced fetch) | on | fetch truncated (237/399); card re-opened and repeated its POST with `X-Admin-Resume: true` -> breakdown-resume works |
| 4 | TLS + script | chunked 100 / single | `/1`, `/`, absolute | keep-alive | off | full fetch, then fatal `unexpected_message` (Next-URI shape) |
| 5 | TLS + script | single | none (`""`) | keep-alive | off | no alert, no POST, session left open (spec: no Next-URI -> no response) |
| 6 | TLS + script | chunked 100 | reference | close_notify after drain | off | full fetch, alert (notify never fetched) |
| 7 | TLS + script | chunked 100 | reference | close_notify before drain | off | full fetch, alert (head split into its own record) |
| 8 | TLS + script | **single record** | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`chunked=false` -> suspected empty script) |
| 9 | TLS + script | single record | reference | keep-alive (no close) | off | fatal `unexpected_message` (close required) |
| 10 | TLS + script | chunked 100 | reference | drain + close_notify | off | full fetch, then alert; later the SD hung until a manual link-dropped event |
| 11 | TLS + script | single record | reference | keep-alive | off | fatal `unexpected_message` after the full fetch (no close) |
| 12 | TLS + script | single record | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`Content-Length`) |
| 13 | TLS + script | chunked100 + single | reference | drain + close_notify | off | no alert, no R-APDU |
| 14 | TLS + script | single record | reference | keep-alive | off | alert again |
| 15 | TLS + script | chunked 100 | reference | keep-alive | on | alert (small records, ruled out record size) |
| 16 | TLS + script | single record | reference | keep-alive, no `Connection` header | on | alert |
| 17 | TLS + script (RFM! `00D6` write-probe) | chunked, single | reference | drain + close_notify | on | no alert, no R-APDU; EF.SPN unchanged - **RFM result is void**: the ISD only accepts RAM commands |
All script attempts used the `explore` list, except #8-#17 which used only
`80CAFF2100` (or the RFM probe). #3-#17 ran with the card's PSK identity
`89390…903` (push trigger) or `89701…` (event trigger).
**Status after #17 (superseded by the 2026-09-16 resolution above):** the
failures were caused by the BIP TLV length bug, not by the HTTP/TLS details;
resume mode was a symptom (the working session even started as a resume). The
key working recipe (also now the server default): one keep-alive connection,
Apache-style headers, `Transfer-Encoding: chunked` body with the script in
one TLS record, no Connection header, `X-Admin-Next-URI` with a query whose
command id increments.
**Also confirmed:** a TLS half-close (close_notify then keep reading for the
card's POST which RFC 5246 leaves open in practice) cannot be done with
CPython's `ssl`: `SSLSocket.unwrap()` with a short timeout raises and poisons
the session (tested), so the `half_close` option is a documented no-op.
## RESOLVED 2026-09-16b: SW CAFE continuation pages
**Implemented:** the script responder auto-follows a truncated listing page
(`SW CAFE`, 127 bytes) by inserting a continuation GET STATUS
(`80F2 <P1> 02 <Lc> 4F <len> <last-complete-AID> 00`, next-occurrence mode)
as the next command. The last AID comes from the last complete `E3` entry in
the page (truncated tails and the live `FC`-prefixed junk are skipped).
Logged as `script-page`; a repeated page logs `script-page-stalled` and
stops; max 24 pages; inserted continuations are dropped at session start.
**Live-verified (2026-09-16):** ELF registry: page 1 `SW CAFE` ->
continuation with `D276000005AA060200000000B00000` -> page 2 `SW 9000`
(complete, 2 entries). Applications: page 1 `SW CAFE` -> continuation with
`D276000005AAFFCAFE0010` -> page 2 `SW 9000` (complete, incl.
`D276000005AAFFCAFE0001/0010`, `A0000001515350`, `A000000151535041`).
Full session: 7/7 commands, all `X-Admin-Script-Status: ok`.
## RESOLVED 2026-09-16c: RAM install over SCP81 - BER length in the script template
**Root cause:** `_scp81_command_body` wrote the C-APDU TLV length as a raw
byte (`AE 80 22 F5 <245 bytes> 00 00` for a 245-byte LOAD). BER reads a byte
above 0x7F as a long-form marker, so the card mis-parsed every LOAD >128
bytes; the small INSTALL commands (<128 bytes) executed normally, which made
the install look alive. Symptoms: the card accepted the LOAD responses with
`X-Admin-Script-Status: ok` but sent a degenerate `AF 80` body, no LOAD
R-APDU appeared in the results, and the final INSTALL [for install] answered
`6A88` (module not found) because the package was never loaded.
**Fix:** both the indefinite ("22" TLV) and definite ("AA" outer) template
lengths are BER-encoded (`_ber_len_bytes`); the same rule as the BIP channel
data TLV fix earlier the same day. Tests cover the 245-byte LOAD body, the
short-form case and the definite variant.
## RESOLVED 2026-09-16d: RAM install - LOAD blocks were overlapping copies
**Root cause:** the LOAD block slicer indexed the load file TLV with the
*block number* (`loadfile_tlv[i * 2:(i + 240) * 2] for i in range(blocks)`)
instead of a *character offset*, so every block after the first was a
1-byte-shifted copy of its predecessor. On the wire the cap header repeated
every 239 bytes. The card accepted the first three blocks and failed block 4
with `SW 6400` (execution error), then refused the rest (`6985`) and the
final INSTALL answered `6A88`. The same slicing lived in the SCP80
/api/ram-install path (the helper was extracted from it), so multi-block caps
could never install there either.
**Fix:** consecutive chunks at char offsets
(`range(0, len(tlv), 240 * 2)`), with a reassembly test that pins the joined
blocks to the C4 TLV byte for byte.
## RESOLVED 2026-09-16e: RAM install over SCP81 - complete and verified
**Live-verified end-to-end**: .cap parse -> INSTALL [for load] (SW 9000) ->
LOAD x6 (all SW 9000, after the block-slicing fix) -> INSTALL [for install]
with the full parameter set -> SW 9000. The applet's INSTALL [for install]
needed the real install parameters (`C900` + the STK parameters
`EA 0C 80 0A ...`), which the compact SCP81 form could not express - the
Remote APDU -> RAM -> INSTALL [for install] builder has all the fields and
its "Queue in SCP81" button feeds the commands straight into the HTTP OTA
script (the "To expanded" button shows them in the TS 102 226 command
scripting (AA/AE80) form). The card's answer for the parameter-less attempt
was SW 6A80 (incorrect parameters in data field).
Working INSTALL [for install] example (compact):
`80E60C002E07AA1902BC22580108AA1902BC2258010108AA1902BC22580101010010C900EA0C800A00000F010000000000000000`
## RESOLVED 2026-09-16f: SW CAFE pagination used the wrong P2 (02 instead of 03)
**Root cause:** the continuation GET STATUS used `P2=02`, which Table 11-34
(GP Card Spec 2.3.1) defines as "**Get first or all occurrence(s)**" - the
card returned the first listing again (with the search criterion's single
match), so every listing appeared to end after one extra page and newly
installed/registered entries were invisible (the installed package
`AA1902BC225801` was missing from the ELF registry). The correct value is
`P2=03` = "**Get next occurrence(s)**".
**Fix:** the continuation repeats the *same* GET STATUS command with P2.b1
set (`80F2 <P1> 03 <same data> 00`) - the pagination state lives in the card.
A changed `4F` criterion is a match filter, not a position: `P2=03` combined
with the last AID as criterion is rejected with SW 6A80, and `P2=02` with it
returns that single match (the duplicate seen earlier). The card's
truncation warning is its proprietary `CA FE`; GP defines `63 10` (Table
11-38) and both trigger the continuation.
**Also fixed (same week):** the explore script's P1 values - per Table 11-33
`P1=40` is *applications and supplementary security domains*, `P1=20` the
*ELF registry* and `P1=10` *ELF+modules*; the script never queried the
ELF-only registry, which is why the installed package `AA1902BC225801` was
invisible. Labels/decoder updated; the remote APDU script builder's P1 map
(0x02 load / 0x0C install / 0x08 make-selectable / 0x40 reg-update / 0x10
extradition) was already correct.
## RESOLVED 2026-09-16g: response R-APDU TLV length also needs BER long form
**Root cause:** `_scp81_parse_response` read the `23` (R-APDU) TLV length as a
raw byte. A listing page above 127 bytes arrives as `AF 80 23 81 FC <252
bytes> 00 00`; the parser took `0x81` as the length, so every page was
silently cut to 127 bytes with a bogus status word (the data's last two
bytes, e.g. `CAFE`/`0001`/`9F70` instead of the real `63 10`). The bogus SW
also stopped the pagination, so later registry entries - including the
installed package `AA1902BC225801` - never appeared.
**Fix:** the response template TLVs use `httpota.ber_len_read` (BER length,
same class of bug as the channel data TLV and the command script template
earlier the same day). Regression tests cover a 250-byte page with
`23 81 FC` and the short-form case.
## VERIFIED 2026-09-16h: the installed applet in all registries
After the response-TLV BER fix, `explore` ran 18 commands / 10 auto
continuation pages (all real statuses: `63 10` -> next, `9000` = complete)
and the installed applet shows up everywhere:
```
80F240 (applications+SDs): AA1902BC22580101 life=07 (SELECTABLE) priv=00 elf=AA1902BC225801
80F220 (ELF registry): AA1902BC225801 life=01 (loaded)
80F210 (ELF+modules): AA1902BC225801 life=01 module=AA1902BC22580101
```
Full RAM-over-HTTP install cycle: .cap -> INSTALL [for load] -> LOAD x6 ->
INSTALL [for install] -> registries.
## Next tests / work
1. **UI:** group the per-page R-APDUs under their logical command in the
SCP81 tab (page merging/decoding for ELF and application listings);
expose the framing options in the tab.
2. **Load/store over SCP81:** implemented - `POST /api/scp81/gen-install`
takes a `.cap`, expands it with the shared `_cap_apdu_sequence` helper
(INSTALL [for load] -> 240-byte LOAD blocks -> INSTALL [for install]) and
returns the APDU list, which the PWA stores as an "Install from .cap"
script (the `.cap` itself is never stored). Live install verified
2026-09-16.
## Tooling
- `tools/scp81_decrypt.py <log.json> <keys.log>` - decrypts the dialog from
`GET /api/scp81/log` plus the listener's `keylog` file (SSLKEYLOGFILE
format; PSK-AES128-CBC-SHA256, TLS 1.2 PRF + OpenSSL CLI). Shows each
record's plaintext and any alert level/description.
- Start the listener with `"keylog": "/tmp/.../scp81.keys"` to collect the
secrets (contains key material - use a temp path, never commit).
+35 -27
View File
@@ -42,9 +42,9 @@
<h3 id="interface" class="text-lg font-medium mb-2">1.1 Интерфейс</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li>
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>. Рядом с заголовком небольшой индикатор показывает состояние сервера/карты (серая точка = подключение, красная = нет сервера, значки карты = нет карты / инициализация / карта готова) и компактный значок <strong>ADM ✓</strong> (зелёный, администраторский PIN подтверждён) или <strong>ADM ✗</strong> (красный, не подтверждён) для текущей сессии карты.</li>
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>&laquo;Парсер ответов&raquo;</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>&laquo;Картридер&raquo;</strong> (<strong>Файловый менеджер</strong>, <strong>Пользовательские файлы</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>), <strong>&laquo;Профайлер&raquo;</strong> и <strong>&laquo;Симулятор телефона&raquo;</strong>.</li>
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>&laquo;Парсер ответов&raquo;</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>), <strong>SCP81</strong> (<strong>&laquo;Слушатель&raquo;</strong>, <strong>&laquo;Скрипты&raquo;</strong>), <strong>&laquo;Карты&raquo;</strong>, <strong>&laquo;Профайлер&raquo;</strong> (вкладки <strong>&laquo;Профили&raquo;</strong>, <strong>&laquo;Снимки карт&raquo;</strong>, <strong>&laquo;Пользовательские файлы&raquo;</strong>), <strong>&laquo;Картридер&raquo;</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>&laquo;Симулятор телефона&raquo;</strong>.</li>
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка &laquo;Профайлер&raquo; открывает &sect;5).</li>
</ul>
@@ -241,7 +241,7 @@
<section class="mb-10">
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>Карты</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p>
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — двумя переключателями: <strong>Secured Packet</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p>
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
<p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p>
@@ -275,7 +275,7 @@
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Карты</h3>
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры.</p>
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, а слушатель SCP81 HTTP OTA — находить PSK-ключи. Вкладка &laquo;Карты&raquo; — верхнеуровневая.</p>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody>
@@ -285,20 +285,22 @@
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Счётчик повторов (5 байт)</td></tr>
<tr><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex-символа (ключи 8/16/24 байта 3DES) или 32/48/64 hex-символа (ключи 16/24/32 байта AES)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex-символа (ключи 8/16/24 байта 3DES) или 32/48/64 hex-символа (ключи 16/24/32 байта AES)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: идентификатор, который карта присылает в TLS-рукопожатии (например, <code class="font-mono text-sm">89390100000129506903</code>)</td></tr>
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex-символа (16 байт). Слушатель выбирает этот ключ, когда карта предъявляет совпадающий идентификатор; предустановка с ключом без идентификатора игнорируется (и помечается в таблице)</td></tr>
</tbody>
</table>
<p class="text-sm mb-3">Обмен предустановками: <strong>Экспорт в JSON</strong> и <strong>Экспорт в файл</strong> для выгрузки, <strong>Импорт из файла</strong>, <strong>Вставить и импортировать</strong> или <strong>Импорт JSON из буфера</strong> для загрузки. Выбранная предустановка автоматически заполняет форму Secured Packet.</p>
<p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в предустановке рабочая пара PSK. Кнопка <strong>Изменить</strong> загружает предустановку в форму (кнопка становится <strong>Сохранить</strong>; <strong>Отмена</strong> очищает форму), поэтому поля можно менять без повторного ввода карты. Обмен предустановками: <strong>Экспорт в JSON</strong> и <strong>Экспорт в файл</strong> для выгрузки, <strong>Импорт из файла</strong>, <strong>Вставить и импортировать</strong> или <strong>Импорт JSON из буфера</strong> для загрузки. Выбранная предустановка автоматически заполняет форму Secured Packet; изменения сразу передаются работающему слушателю SCP81.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.3 RAM</h3>
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты из подвкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик.</p>
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик.</p>
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Операция</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)].</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU, каждый из которых помещается в один SMS SCP80; поле <strong>размер блока LOAD</strong> переопределяет авто-подобранный размер (пусто = максимальный размер, чей secured-пакет укладывается в 140 октетов), так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
</tbody>
</table>
@@ -314,32 +316,30 @@
<section class="mb-10">
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Картридер&raquo; (pySim)</h2>
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Пользовательские файлы</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>&laquo;Профайлер&raquo;</strong> и <strong>&laquo;Симулятор телефона&raquo;</strong> — отдельные вкладки верхнего уровня.</p>
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>&laquo;Профайлер&raquo;</strong> и <strong>&laquo;Симулятор телефона&raquo;</strong> — отдельные вкладки верхнего уровня.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа.</p>
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
</ul>
<h3 id="custom-files" class="text-lg font-medium mb-2">4.2 Пользовательские файлы</h3>
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве &laquo;Файловый менеджер&raquo;. Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.3 Командная строка pySim</h3>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
<p class="text-sm mb-3">Выполнение любых команд pySim-shell с подсказками (300&nbsp;мс) и автодополнением.</p>
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.4 Отправка APDU</h3>
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Отправка APDU</h3>
<p class="text-sm mb-3">Отправка произвольного APDU и просмотр ответа.</p>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.5 Сценарии использования</h3>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Сценарии использования</h3>
<h4 id="scenario-a" class="font-medium mb-1">Сценарий A &mdash; Работа с файлами, не входящими в модель pySim (&laquo;Пользовательские файлы&raquo;)</h4>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Получите FID целевого файла (документация вендора или анализ ATR/файловой системы; такие файлы часто отсутствуют в открытых спецификациях).</li>
<li>Откройте вкладку <strong>&laquo;Картридер&raquo;</strong> &rarr; подвкладку <strong>&laquo;Пользовательские файлы&raquo;</strong>.</li>
<li>Введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Откройте вкладку <strong>&laquo;Профайлер&raquo;</strong> &rarr; подвкладку <strong>&laquo;Пользовательские файлы&raquo;</strong>.</li>
<li>Выберите корень, введите путь родительского DF (стандартный DF из дерева или пользовательский DF, любой вложенности &mdash; при вводе появляются подсказки), 4-hex FID и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Нажмите <strong>Добавить</strong> — файл появится в дереве курсивом (непроверенный).</li>
<li>Кликните по файлу для проверки существования; при успехе (<code class="font-mono text-sm">9000</code>) он работает как обычный файл.</li>
<li>Читайте, редактируйте и сохраняйте hex-данные; переключайте <strong>Данные как на карте</strong> / <strong>Декодированные данные</strong>.</li>
@@ -378,8 +378,9 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
<li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</li>
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Клонировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>. <strong>Клонировать</strong> создаёт копию профиля с именем <em>Копия &lt;профиль&gt;</em> (с суффиксом <code class="font-mono text-sm">(2)</code>, <code class="font-mono text-sm">(3)</code>…, если такое имя уже занято) и открывает копию в редакторе, чтобы изменить имя и содержимое перед сохранением.</li>
</ul>
<h4 class="font-medium mb-1">Правила файловой системы</h4>
<p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p>
@@ -389,9 +390,9 @@
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
</ul>
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны <em>ожидалось (имя профиля)</em> и <em>фактически (ICCID карты)</em>, а в заголовке отчёта выводится <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;ICCID карты&gt;</em>. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
<h4 class="font-medium mb-1">Опции сканирования &laquo;Профиль с карты&raquo;</h4>
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p>
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования. Тот же диалог и опции использует <strong>&laquo;Профиль из снимка&raquo;</strong>: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).</p>
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
@@ -399,19 +400,23 @@
<li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li>
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;имя снимка&gt;</em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля, но <strong>всегда точно</strong> (маскирование содержимого не применяется): выберите <em>эталонный</em> снимок и <em>снимок для проверки</em> и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: &lt;эталон&gt; &rarr; &lt;проверяемый&gt;</em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
</ul>
</section>
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim. Пользовательский файл всегда имеет полный путь от <strong>MF</strong> или ADF (<strong>ADF.USIM</strong>, <strong>ADF.ISIM</strong>): выберите корень, введите путь <strong>родительского DF</strong> (сам корень, стандартный DF из дерева файлового менеджера или пользовательский DF &mdash; любой вложенности, с подсказками при вводе; родитель, ещё не встречавшийся в дереве, остаётся допустимым и помечается <code class="font-mono text-sm"></code>), 4-hex <strong>FID</strong> и псевдоним (<code class="font-mono text-sm">EF.&hellip;</code>/<code class="font-mono text-sm">DF.&hellip;</code>; префикс определяет, EF это или DF). Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Добавленные файлы появляются в дереве &laquo;Файловый менеджер&raquo;; в каждой строке есть <strong>&laquo;Редактировать&raquo;</strong> (путь + псевдоним; изменение пути DF перепривязывает дочерние записи &mdash; кнопка становится <strong>&laquo;Сохранить&raquo;</strong>, <strong>&laquo;Отмена&raquo;</strong> отменяет) и <strong>&laquo;Удалить&raquo;</strong> (удаление DF удаляет и его дочерние записи после подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен &mdash; <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> / <strong>Импорт</strong>; старые относительные пути (например, <code class="font-mono text-sm">a153/4954</code>) разрешаются при загрузке, неразрешимые отбрасываются и отмечаются в списке.</p>
<section class="mb-10">
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Симулятор телефона</h2>
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 Меню STK</h3>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает).</p>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Подписанные события (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
@@ -424,12 +429,15 @@
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Журнал проактивных команд</h3>
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Раскрытие строки показывает декодированную команду: текст и пункты DISPLAY TEXT / SELECT ITEM / SET UP MENU, параметры BIP-каналов, действия TIMER MANAGEMENT, названия квалификаторов PROVIDE LOCAL INFORMATION (все стандартные, включая ESN 07, MEID 0B и Supported RATs 1A) и SEND SHORT MESSAGE — разбирается SMS TPDU (тип, TP-MR, TP-DA, TP-PID, TP-DCS, срок действия, TP-UDL), а пользовательские данные показываются как текст для текстовых кодировок (GSM&nbsp;7-бит, UCS2, 8-бит) или как secured-пакет (TS&nbsp;31.115) при TP-PID&nbsp;=&nbsp;<code class="font-mono text-sm">7F</code> (SIM data download); заголовки конкатенации UDH декодируются. Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 Опрос STATUS</h3>
<h3 id="terminal-profile" class="text-lg font-medium mb-2">6.4 TERMINAL PROFILE</h3>
<p class="text-sm mb-3">Блок <strong>TERMINAL PROFILE</strong> (рядом с «STATUS и опрос») содержит кнопки <strong>«Отправить»</strong> (повторная отправка, как «Спасение») и <strong>«Настроить»</strong>. В диалоге настройки: селектор пресетов (модели устройств, например профиль BIP-совместимого аппарата из этого проекта), поле hex и форма с флажком на каждый бит профиля по <strong>TS 102 223 §5.2</strong> (байты 1&ndash;33; далее — обобщённые подписи); каждый байт — вертикальный список битов; блоки размещены фиксированными группами (байты 1–12 в 2 колонки, 1316 в 4, 1718 в 2, 1921 в 3, 2225 в 2, 2628 в 3, 2930 в 2, далее по одному в строке); переключение бита обновляет hex, а правка hex перерисовывает форму &mdash; поле hex основное, неизвестные байты и биты сохраняются. Кнопка <strong>«Применить»</strong> отправляет новое значение на сервер (и далее на карту), сбрасывая STK-сессию как «Спасение»; изменение хранится только в памяти (значение <code class="font-mono text-sm">--terminal-profile</code> — стартовое по умолчанию).</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.5 Опрос STATUS</h3>
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;с, по умолчанию 30&nbsp;с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 &laquo;Конфигурация TR&raquo; &mdash; данные ответа PROVIDE LOCAL INFORMATION</h3>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.6 &laquo;Конфигурация TR&raquo; &mdash; данные ответа PROVIDE LOCAL INFORMATION</h3>
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 &sect;8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
+34 -26
View File
@@ -42,9 +42,9 @@
<h3 id="interface" class="text-lg font-medium mb-2">1.1 Interface</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li>
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle. Next to the title a small status indicator shows the server/card state (gray dot = connecting, red dot = no server connection, card icons = no card / equipping / equipped) and a compact <strong>ADM ✓</strong> (green, administrator PIN verified) or <strong>ADM ✗</strong> (red, not verified) badge for the current card session.</li>
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Cards</strong>, <strong>RAM</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>Custom files</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), <strong>Profiler</strong>, and <strong>Phone simulator</strong>.</li>
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>), <strong>SCP81</strong> (<strong>Listener</strong>, <strong>Scripts</strong>), <strong>Cards</strong>, <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Phone simulator</strong>.</li>
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens &sect;5).</li>
</ul>
@@ -275,7 +275,7 @@
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>Response parser</strong> tab, and a successful PoR advances the replay counter and clears the packet.</p>
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Cards</h3>
<p class="mb-2">Stores card presets locally in the browser (<code class="font-mono text-sm">localStorage</code>) so the Secured Packet view can auto-fill keys and parameters.</p>
<p class="mb-2">Stores card presets locally in the browser (<code class="font-mono text-sm">localStorage</code>) so the Secured Packet view can auto-fill keys and parameters, and so the SCP81 HTTP OTA listener can look up PSK keys. The Cards tab is a top-level tab.</p>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody>
@@ -285,20 +285,22 @@
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Replay counter (5 bytes)</td></tr>
<tr><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: the identity the card sends in the TLS handshake (e.g. <code class="font-mono text-sm">89390100000129506903</code>)</td></tr>
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex chars (16 bytes). The listener selects this key when the card presents the matching identity; a preset with a key but no identity is ignored (and flagged in the table)</td></tr>
</tbody>
</table>
<p class="text-sm mb-3">Presets can be shared with <strong>Export as JSON</strong> and <strong>Export to file</strong>, and restored with <strong>Import from file</strong>, <strong>Paste &amp; import</strong>, or <strong>Import JSON from clipboard</strong>. The selected card preset auto-fills the Secured Packet form.</p>
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair. <strong>Edit</strong> loads a preset into the form (the button becomes <strong>Save</strong>; <strong>Cancel</strong> clears it) so fields can be changed without re-entering the card. Presets can be shared with <strong>Export as JSON</strong> and <strong>Export to file</strong>, and restored with <strong>Import from file</strong>, <strong>Paste &amp; import</strong>, or <strong>Import JSON from clipboard</strong>. The selected card preset auto-fills the Secured Packet form; edits are pushed into a running SCP81 listener automatically.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.3 RAM</h3>
<p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> sub-tab provides the SPI, keys, TAR, and counter.</p>
<p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> tab provides the SPI, keys, TAR, and counter.</p>
<h4 id="ram-operations" class="font-medium mb-1">Operations</h4>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Operation</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item <strong>Delete</strong> buttons.</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)].</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs that each fit one SCP80 SMS; the <strong>LOAD block size</strong> field overrides the auto-fitted size (empty = largest size whose secured packet still encodes into 140 octets), so a large <code>.cap</code> simply takes several SMS.</td></tr>
</tbody>
</table>
@@ -314,32 +316,30 @@
<section class="mb-10">
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Card reader (pySim) tab</h2>
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>Custom files</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right.</p>
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
</ul>
<h3 id="custom-files" class="text-lg font-medium mb-2">4.2 Custom files</h3>
<p class="text-sm mb-3">Add files that pySim&rsquo;s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste &amp; import</strong> / <strong>Import JSON from clipboard</strong>.</p>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.3 pySim command line</h3>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
<p class="text-sm mb-3">Execute any pySim-shell command with usage hints (300&nbsp;ms) and autocomplete.</p>
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.4 Raw APDU</h3>
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Raw APDU</h3>
<p class="text-sm mb-3">Send an arbitrary APDU and view the raw response.</p>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.5 Usage scenarios</h3>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Usage scenarios</h3>
<h4 id="scenario-a" class="font-medium mb-1">Scenario A &mdash; Working with files not in pySim&rsquo;s model (Custom files)</h4>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Obtain the FID of the target file (vendor documentation or ATR/file-system analysis; such files are often not in public specs).</li>
<li>Open the <strong>Card reader</strong> tab &rarr; <strong>Custom files</strong> sub-tab.</li>
<li>Enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Open the <strong>Profiler</strong> tab &rarr; <strong>Custom files</strong> sub-tab.</li>
<li>Pick the root, type the parent DF path (a standard DF from the tree or a custom DF, any depth &mdash; suggestions appear while you type), the 4-hex FID and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Click <strong>Add</strong> — the file appears in the tree in italics (unverified).</li>
<li>Click the file to verify existence; on success (<code class="font-mono text-sm">9000</code>) it behaves like a normal file.</li>
<li>Read, edit and save hex data; toggle Raw/Decoded views.</li>
@@ -378,8 +378,9 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
<li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li>
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Clone</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions. <strong>Clone</strong> copies the profile under the name <em>Copy of &lt;profile&gt;</em> (with a <code class="font-mono text-sm">(2)</code>, <code class="font-mono text-sm">(3)</code>… suffix when that name already exists) and opens the copy in the editor, so both the name and the contents can be adjusted before saving.</li>
</ul>
<h4 class="font-medium mb-1">Filesystem rules</h4>
<p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule&rsquo;s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p>
@@ -389,9 +390,9 @@
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
</ul>
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;card ICCID&gt;</em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
<h4 class="font-medium mb-1">&ldquo;Profile from card&rdquo; scan options</h4>
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check. The same dialog and options are used by <strong>Profile from snapshot</strong>, which walks the selected snapshot&rsquo;s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).</p>
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
@@ -399,19 +400,23 @@
<li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li>
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check: the actual side is labelled with the snapshot name (<em>actual (snapshot name)</em>) and the header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;snapshot name&gt;</em>; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check but <strong>always exact</strong> (no content masking): pick the <em>master</em> snapshot and the <em>snapshot to check</em> and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: &lt;master&gt; &rarr; &lt;checked&gt;</em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
</ul>
</section>
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
<p class="text-sm mb-3">Add files that pySim&rsquo;s model does not cover. A custom file always has a full path rooted at <strong>MF</strong> or an ADF (<strong>ADF.USIM</strong>, <strong>ADF.ISIM</strong>): pick the root, type the <strong>parent DF</strong> path (the root itself, a standard DF known from the file-manager tree, or a custom DF &mdash; any depth, with suggestions while you type; a parent that has not been seen in the tree yet stays valid and is marked <code class="font-mono text-sm"></code>), the 4-hex <strong>FID</strong> and an alias (<code class="font-mono text-sm">EF.&hellip;</code>/<code class="font-mono text-sm">DF.&hellip;</code>; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has <strong>Edit</strong> (path + alias; changing a DF&rsquo;s path re-points its child entries &mdash; the button becomes <strong>Save</strong>, <strong>Cancel</strong> aborts) and <strong>Delete</strong> (deleting a DF also deletes its child entries after a confirmation). The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> / <strong>Import</strong>; legacy relative paths (e.g. <code class="font-mono text-sm">a153/4954</code>) are resolved on load, unresolvable ones are dropped and reported in the list.</p>
<section class="mb-10">
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Phone simulator</h2>
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 STK menu</h3>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables).</p>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Subscribed events (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
@@ -424,12 +429,15 @@
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Proactive command log</h3>
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Expanding a row shows the decoded command: DISPLAY TEXT / SELECT ITEM / SET UP MENU text and items, BIP channel parameters, TIMER MANAGEMENT actions, PROVIDE LOCAL INFORMATION qualifier names (all standard qualifiers, including ESN 07, MEID 0B and Supported RATs 1A), and SEND SHORT MESSAGE &mdash; the SMS TPDU is parsed (type, TP-MR, TP-DA, TP-PID, TP-DCS, validity period, TP-UDL) with the user data shown as text for text codings (GSM&nbsp;7-bit, UCS2, 8-bit) or as a secured packet (TS&nbsp;31.115) when TP-PID&nbsp;=&nbsp;<code class="font-mono text-sm">7F</code> (SIM data download); UDH concatenation headers are decoded. Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 STATUS polling</h3>
<h3 id="terminal-profile" class="text-lg font-medium mb-2">6.4 TERMINAL PROFILE</h3>
<p class="text-sm mb-3">The <strong>TERMINAL PROFILE</strong> block (next to STATUS and Polling) offers <strong>Send</strong> (re-sends it, like Rescue) and <strong>Configure</strong>. The Configure dialog has a preset selector (device models, e.g. this project's BIP-capable handset profile), a hex field and a form with one checkbox per profile bit decoded per <strong>TS 102 223 §5.2</strong> (bytes 1&ndash;33; later bytes get generic labels), each byte is a vertical list of its bits, placed in fixed column groups (bytes 1-12 in 2 columns, 13-16 in 4, 17-18 in 2, 19-21 in 3, 22-25 in 2, 26-28 in 3, 29-30 in 2, later bytes one per row); toggling a bit updates the hex and editing the hex re-renders the form &mdash; the hex field is authoritative and unknown bytes/bits are preserved. <strong>Apply</strong> sends the new value to the server (and on to the card), resetting the STK session like Rescue; the change is in-memory only (the <code class="font-mono text-sm">--terminal-profile</code> CLI value is the startup default).</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.5 STATUS polling</h3>
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;s, default 30&nbsp;s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 TR Config &mdash; PROVIDE LOCAL INFORMATION response data</h3>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.6 TR Config &mdash; PROVIDE LOCAL INFORMATION response data</h3>
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 &sect;8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
+2825 -479
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v103';
const CACHE = 'otaman-v176';
const URLS = [
'index.html',
'help.html',
+131 -4
View File
@@ -21,21 +21,54 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1);
}
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n';
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n';
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n';
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
code += extractFunc(html, 'pysimUpdateAdmIndicator') + '\n';
code += extractFunc(html, 'pysimSetServerAvailable') + '\n';
code += '\nglobalThis.esc = s => s;\n';
code += 'globalThis.t = s => s;\n';
eval(code);
function fakeIndicator() {
const classes = new Set();
const el = {
classes, textContent: '', title: null,
classList: {
add: (...c) => c.forEach(x => classes.add(x)),
remove: (...c) => c.forEach(x => classes.delete(x)),
contains: c => classes.has(c),
},
setAttribute: (k, v) => { if (k === 'title') el.title = v; },
removeAttribute: (k) => { if (k === 'title') el.title = null; },
};
return el;
}
function setup() {
const el = { textContent: 'status line', innerHTML: '' };
const calls = { connected: [], resets: [], refreshStatus: [] };
const adm = fakeIndicator();
const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
_pysimCardStateKey = null;
_pysimCardSession = null;
globalThis.document = { getElementById: () => el };
_pysimProactiveSeq = null;
_pysimAdmVerified = null;
_pysimServerAvailable = null;
globalThis.document = {
getElementById: id => id === 'state-indicator-adm' ? adm : el,
querySelectorAll: () => [],
};
globalThis.pysimSetConnected = v => calls.connected.push(v);
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
return { el, calls };
globalThis.pysimApplyAvailability = () => {};
globalThis.isViewVisible = () => true;
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
return { el, adm, calls };
}
function status(extra) {
@@ -98,3 +131,97 @@ test('payload without connected flag is ignored', () => {
pysimCardStateUpdate(null);
assert.deepStrictEqual(calls.connected, []);
});
test('availability state and control gating follow server/card state', () => {
_pysimServerAvailable = null;
assert.strictEqual(pysimAvailabilityState(), 'server-down');
assert.strictEqual(pysimControlDisabled('server', 'server-down'), true);
assert.strictEqual(pysimControlDisabled('card', 'server-down'), true);
_pysimServerAvailable = true;
_pysimCardEquipped = false;
assert.strictEqual(pysimAvailabilityState(), 'no-card');
assert.strictEqual(pysimControlDisabled('server', 'no-card'), false);
assert.strictEqual(pysimControlDisabled('card', 'no-card'), true);
_pysimCardEquipped = true;
assert.strictEqual(pysimAvailabilityState(), 'card');
assert.strictEqual(pysimControlDisabled('card', 'card'), false);
assert.strictEqual(pysimControlDisabled('server', 'card'), false);
});
test('no card with auto-equip enabled still shows the no-card message', () => {
const { el } = setup();
pysimCardStateUpdate(status({ connected: false, card_present: false, auto_equip: true }));
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
});
test('proactive log refreshes when the status sequence changes', () => {
const { calls } = setup();
pysimCardStateUpdate(status({ proactive_seq: 7 }));
pysimCardStateUpdate(status({ proactive_seq: 7 }));
assert.strictEqual(calls.proactive, 1);
pysimCardStateUpdate(status({ proactive_seq: 8 }));
assert.strictEqual(calls.proactive, 2);
});
test('proactive log is not refreshed while the phone view is hidden', () => {
const { calls } = setup();
globalThis.isViewVisible = () => false;
pysimCardStateUpdate(status({ proactive_seq: 3 }));
assert.strictEqual(calls.proactive, 0);
});
test('pysimProactiveSeqChanged tracks the last sequence', () => {
_pysimProactiveSeq = null;
assert.ok(pysimProactiveSeqChanged(4));
assert.ok(!pysimProactiveSeqChanged(4));
assert.ok(pysimProactiveSeqChanged(5));
assert.ok(!pysimProactiveSeqChanged(undefined));
assert.ok(!pysimProactiveSeqChanged(null));
});
test('pysimStkStatusChanged detects menu state transitions', () => {
_pysimStkSig = null;
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
assert.ok(!pysimStkStatusChanged(null));
});
test('the header ADM badge shows verified / not verified / hidden', () => {
const { adm, calls } = setup();
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
assert.ok(!adm.classes.has('hidden'));
assert.strictEqual(adm.textContent, 'ADM ✓');
assert.ok(adm.classes.has('text-emerald-600'));
assert.ok(adm.classes.has('dark:text-emerald-400'));
assert.strictEqual(adm.title, 'ADM verified');
// an unchanged state must not rewrite the badge
adm.textContent = '';
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
assert.strictEqual(adm.textContent, '', 'unchanged ADM state rewrote the badge');
// verification lost (e.g. card reset)
pysimCardStateUpdate(status({ connected: true, adm_verified: false }));
assert.strictEqual(adm.textContent, 'ADM ✗');
assert.ok(adm.classes.has('text-red-500'));
assert.ok(!adm.classes.has('text-emerald-600'));
assert.strictEqual(adm.title, 'ADM not verified');
// no card session hides it
pysimCardStateUpdate(status({ connected: false }));
assert.ok(adm.classes.has('hidden'));
assert.strictEqual(adm.title, null);
// the ADM update must not disturb the connect/reset flow
assert.deepStrictEqual(calls.connected, [true, false]);
});
test('losing the server hides the ADM badge', () => {
const { adm } = setup();
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
assert.ok(!adm.classes.has('hidden'));
pysimSetServerAvailable(false);
assert.ok(adm.classes.has('hidden'));
});
+299
View File
@@ -0,0 +1,299 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\nvar pysimFsTreeRoot = null;\n';
for (const fn of ['pysimCustomNormPath', 'pysimCustomKindForName', 'pysimCustomFid',
'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomKnownDfPaths', 'pysimCustomParentStatus',
'pysimCustomValidate', 'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries',
'pysimCustomSave', 'pysimCustomRenderRoots', 'pysimCustomRenderParents',
'pysimCustomRootChanged', 'pysimCustomParentInput', 'pysimCustomSubmit',
'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender',
'pysimFsNodePath', 'pysimFsFindNodeByPath', 'pysimCustomInject', 'pysimCustomRefreshTree']) {
code += extractFunc(html, fn) + '\n';
}
code += html.match(/const CUSTOM_ROOTS = \[[^\]]*\];/)[0].replace('const ', 'var ') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimFsRenderTree = () => {};\n';
eval(code);
function fakeEl(id) {
const classes = new Set();
return {
id, value: '', innerHTML: '', textContent: '', attrs: {}, focused: 0,
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
},
setAttribute(k, v) { this.attrs[k] = v; },
focus() { this.focused++; },
options() { return [...this.innerHTML.matchAll(/value="([^"]+)"/g)].map(m => m[1]); },
};
}
function setup(entries) {
const els = {
'pysim-cf-root': fakeEl('pysim-cf-root'),
'pysim-cf-parent': fakeEl('pysim-cf-parent'),
'pysim-cf-parent-list': fakeEl('pysim-cf-parent-list'),
'pysim-cf-fid': fakeEl('pysim-cf-fid'),
'pysim-cf-name': fakeEl('pysim-cf-name'),
'pysim-cf-list': fakeEl('pysim-cf-list'),
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
'pysim-cf-cancel-btn': fakeEl('pysim-cf-cancel-btn'),
};
els['pysim-cf-cancel-btn'].classList.add('hidden');
const store = {};
globalThis.localStorage = {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
};
globalThis.document = { getElementById: id => els[id] || null };
globalThis.alertCalls = [];
globalThis.alert = m => { globalThis.alertCalls.push(m); };
globalThis.confirmResult = true;
globalThis.confirm = () => globalThis.confirmResult;
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
pysimCustomEditIndex = null;
_pysimCustomDropped = 0;
pysimFsTreeRoot = null;
pysimCustomRenderRoots();
pysimCustomRenderParents();
return els;
}
function fill(els, root, parent, fid, name) {
els['pysim-cf-root'].value = root;
pysimCustomRenderParents();
els['pysim-cf-parent'].value = parent;
els['pysim-cf-fid'].value = fid;
els['pysim-cf-name'].value = name;
}
test('helpers derive canonical roots, parents, FIDs and kinds', () => {
assert.strictEqual(pysimCustomNormPath('3f00/7f20/6f46'), 'MF/7F20/6F46');
assert.strictEqual(pysimCustomNormPath(' mf / a153 '), 'MF/A153');
assert.strictEqual(pysimCustomNormPath(''), '');
assert.strictEqual(pysimCustomRoot({ path: 'ADF.USIM/6F07' }), 'ADF.USIM');
assert.strictEqual(pysimCustomParent({ path: 'MF/A153/4954' }), 'MF/A153');
assert.strictEqual(pysimCustomParent({ path: 'MF/6F46' }), 'MF');
assert.strictEqual(pysimCustomFid({ path: 'MF/A153/4954' }), '4954');
assert.strictEqual(pysimCustomKindForName('EF.SPN'), 'ef');
assert.strictEqual(pysimCustomKindForName('DF.GSM'), 'df');
assert.strictEqual(pysimCustomKindForName('XX.SPN'), null);
});
test('migration resolves legacy relative paths and drops the unresolvable', () => {
const res = pysimCustomNormalizeEntries([
{ path: '3f00/a153', name: 'DF.A1' },
{ path: 'a153/4954', name: 'EF.SPNS', fid: '4954', parentFid: 'a153' },
{ path: 'a153/4955', name: 'EF.SMSCS' },
{ path: 'ffff/1111', name: 'EF.ORPHAN' }, // no such custom DF -> dropped
{ path: 'MF/6F46', name: 'BAD.NAME' }, // invalid alias -> dropped
], []);
assert.deepStrictEqual(res.files, [
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
]);
assert.strictEqual(res.dropped, 2);
});
test('validation accepts standard/unknown parents but rejects known EFs', () => {
const files = [{ path: 'MF/A153', name: 'DF.A1', kind: 'df' }];
// a parent that is not a custom entry is accepted: it may be a standard DF
// from the card model, or simply not seen in the tree yet
const unknown = pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null);
assert.strictEqual(unknown.error, null);
assert.strictEqual(unknown.path, 'MF/A999/6F46');
// parent defined -> ok
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46');
// a bare FID chain typed without the root is completed from the selector
assert.strictEqual(pysimCustomValidate('MF', 'A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46');
// deep chains are fine
assert.strictEqual(pysimCustomValidate('MF', 'MF/7F20/5F01', '6F46', 'EF.DEEP', [], null).path, 'MF/7F20/5F01/6F46');
// a parent known here to be an EF is rejected
assert.match(pysimCustomValidate('MF', 'MF/A153/6F46', '1234', 'EF.X',
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /not a DF/);
// parent segments must be 4-hex FIDs
assert.match(pysimCustomValidate('MF', 'MF/FOO', '6F46', 'EF.SPN', files, null).error, /4-hex/);
// bad FID
assert.match(pysimCustomValidate('MF', 'MF', '6F4', 'EF.SPN', files, null).error, /4 hex/);
// bad alias
assert.match(pysimCustomValidate('MF', 'MF', '6F46', 'SPN', files, null).error, /EF\.|DF\./);
// duplicate
assert.match(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /already defined/);
// editing the same entry is not a duplicate
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), 1).error, null);
// root must be known
assert.match(pysimCustomValidate('MFX', 'MFX', '6F46', 'EF.SPN', files, null).error, /Root/);
});
test('root and parent suggestions list roots, custom DFs and tree DFs', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'ADF.USIM/6F07', name: 'EF.IMSI', kind: 'ef' },
]);
assert.deepStrictEqual(els['pysim-cf-root'].options(), ['MF', 'ADF.USIM', 'ADF.ISIM']);
let parents = els['pysim-cf-parent-list'].options();
assert.ok(parents.includes('MF'));
assert.ok(parents.includes('MF/A153'));
assert.ok(!parents.includes('MF/A153/4954'), 'EFs are not parent options');
// DFs known from the loaded file tree are suggested, at any depth
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
const df = { name: 'DF.TELECOM', fid: '7F10', isDir: true, parent: pysimFsTreeRoot, children: [] };
const sub = { name: 'DF.SUB', fid: '5F01', isDir: true, parent: df, children: [] };
df.children = [sub];
pysimFsTreeRoot.children = [df];
pysimCustomRenderParents();
parents = els['pysim-cf-parent-list'].options();
assert.ok(parents.includes('MF/7F10'));
assert.ok(parents.includes('MF/7F10/5F01'));
// the ADF root is always a valid parent
els['pysim-cf-root'].value = 'ADF.USIM';
pysimCustomRootChanged();
assert.deepStrictEqual(els['pysim-cf-parent-list'].options(), ['ADF.USIM']);
});
test('parent status classifies root, custom, tree and unknown parents', () => {
setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
]);
assert.strictEqual(pysimCustomParentStatus('MF').status, 'root');
assert.strictEqual(pysimCustomParentStatus('MF/A153').status, 'df');
assert.strictEqual(pysimCustomParentStatus('MF/A153/4954').status, 'not-df');
assert.strictEqual(pysimCustomParentStatus('MF/FFFF').status, 'unknown');
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
const df = { name: 'DF.GSM', fid: '7F20', isDir: true, parent: pysimFsTreeRoot, children: [] };
df.children = [{ name: 'EF.SPN', fid: '6F46', isDir: false, parent: df, children: null }];
pysimFsTreeRoot.children = [df];
assert.strictEqual(pysimCustomParentStatus('MF/7F20').status, 'df');
assert.strictEqual(pysimCustomParentStatus('MF/7F20/6F46').status, 'not-df');
assert.strictEqual(pysimCustomParentStatus('MF/7F20/9999').status, 'unknown');
});
test('import keeps canonical entries whose parent is outside the custom list', () => {
const res = pysimCustomNormalizeEntries([
{ path: '3F00/7f20/5f01/6f46', name: 'EF.DEEP' },
{ path: 'mf/ffff/6f46', name: 'EF.ORPHAN' },
{ path: 'MF/6F46', name: 'NOPE' },
], []);
assert.deepStrictEqual(res.files, [
{ path: 'MF/7F20/5F01/6F46', name: 'EF.DEEP', kind: 'ef' },
{ path: 'MF/FFFF/6F46', name: 'EF.ORPHAN', kind: 'ef' },
]);
assert.strictEqual(res.dropped, 1);
});
test('submit adds files under the root and under a defined DF', () => {
const els = setup([]);
fill(els, 'MF', 'MF', '6F46', 'EF.SPN');
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles, [{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' }]);
assert.strictEqual(els['pysim-cf-fid'].value, '');
// add a DF, then a file under it
fill(els, 'MF', 'MF', 'A153', 'DF.A1');
pysimCustomSubmit();
fill(els, 'MF', 'MF/A153', '4954', 'EF.SPNS');
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
['MF/6F46', 'MF/A153', 'MF/A153/4954']);
// a parent that is not a custom entry is allowed (it may be a standard DF
// the tree has not loaded yet)
fill(els, 'MF', 'MF/A153', '2222', 'EF.ORPHAN');
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
['MF/6F46', 'MF/A153', 'MF/A153/4954', 'MF/A153/2222']);
assert.strictEqual(globalThis.alertCalls.length, 0);
// a parent known here to be an EF is rejected
fill(els, 'MF', 'MF/A153/4954', '3333', 'EF.NOPE');
pysimCustomSubmit();
assert.deepStrictEqual(globalThis.alertCalls, ['Parent is not a DF: MF/A153/4954']);
});
test('editing a DF FID rewrites its descendants', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
]);
pysimCustomEdit(0);
assert.strictEqual(els['pysim-cf-fid'].value, 'A153');
els['pysim-cf-fid'].value = 'A154';
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
['MF/A154', 'MF/A154/4954', 'MF/A154/4955']);
assert.strictEqual(pysimCustomEditIndex, null);
});
test('editing an entry to another defined path is rejected', () => {
const els = setup([
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
{ path: 'MF/6F44', name: 'EF.SPN2', kind: 'ef' },
]);
pysimCustomEdit(0);
els['pysim-cf-fid'].value = '6F44';
pysimCustomSubmit();
assert.deepStrictEqual(globalThis.alertCalls, ['File already defined: MF/6F44']);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46', 'MF/6F44']);
assert.strictEqual(pysimCustomEditIndex, 0);
});
test('deleting a DF cascades to its children only after confirmation', () => {
setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
]);
globalThis.confirmResult = false;
pysimCustomRemove(0);
assert.strictEqual(pysimCustomFiles.length, 3, 'cancelled delete must keep everything');
globalThis.confirmResult = true;
pysimCustomRemove(0);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46']);
});
test('render shows the canonical path, kind and row actions', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
]);
pysimCustomRender();
const out = els['pysim-cf-list'].innerHTML;
assert.match(out, /MF\/A153/);
assert.match(out, /MF\/A153\/4954/);
assert.match(out, /\(DF\)/);
assert.match(out, /pysimCustomEdit\(0\)/);
assert.match(out, /pysimCustomRemove\(1\)/);
});
test('render flags dropped legacy entries', () => {
const els = setup([]);
_pysimCustomDropped = 3;
pysimCustomRender();
assert.match(els['pysim-cf-list'].innerHTML, /3/);
_pysimCustomDropped = 0;
});
+125
View File
@@ -0,0 +1,125 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimCustomFiles = [];\nvar pysimFsTreeRoot = null;\n';
for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomRefreshTree', 'pysimCustomParent', 'pysimCustomFid']) {
code += extractFunc(html, fn) + '\n';
}
code += 'globalThis.pysimFsRenderTree = () => {};\n';
eval(code);
function node(name, fid, parent, children) {
return { name, fid, parent: parent || null, children: children || null, isDir: true };
}
function tree() {
const mf = node('MF', '3F00', null, []);
const dfA = node('DF.A', '5F01', mf);
const dfB = node('DF.B', '5F02', mf);
const dfC = node('DF.C', '5F03', mf, [node('EF.X', '6F46', null)]);
dfC.children[0].parent = dfC;
const adf = node('ADF.USIM', '7FFF', mf);
const efImsi = node('EF.IMSI', '6F07', adf);
adf.children = [efImsi];
mf.children = [dfA, dfB, dfC, adf];
return { mf, dfA, dfB, dfC, adf, efImsi };
}
test('pysimFsNodePath builds canonical paths from MF and ADF roots', () => {
const t = tree();
assert.strictEqual(pysimFsNodePath(t.mf), 'MF');
assert.strictEqual(pysimFsNodePath(t.dfA), 'MF/5F01');
assert.strictEqual(pysimFsNodePath(t.dfC.children[0]), 'MF/5F03/6F46');
assert.strictEqual(pysimFsNodePath(t.adf), 'ADF.USIM');
assert.strictEqual(pysimFsNodePath(t.efImsi), 'ADF.USIM/6F07');
});
test('injection matches full paths, so same-FID DFs stay apart', () => {
const t = tree();
pysimCustomFiles = [
{ path: 'MF/5F01/6F46', name: 'EF.ONLY-A', kind: 'ef' },
{ path: 'MF/5F02/6F46', name: 'EF.ONLY-B', kind: 'ef' },
{ path: 'ADF.USIM/6F07', name: 'EF.MY-IMSI', kind: 'ef' },
];
pysimCustomInject(t.mf);
assert.strictEqual(t.mf.children.length, 4, 'MF-level injection adds nothing');
pysimCustomInject(t.dfA);
assert.strictEqual(t.dfA.children.length, 1);
assert.strictEqual(t.dfA.children[0].name, 'EF.ONLY-A');
assert.strictEqual(t.dfA.children[0].customPath, 'MF/5F01/6F46');
pysimCustomInject(t.dfB);
assert.strictEqual(t.dfB.children[0].name, 'EF.ONLY-B');
pysimCustomInject(t.dfC);
assert.strictEqual(t.dfC.children.length, 1, 'unrelated DF is untouched');
pysimCustomInject(t.adf);
assert.strictEqual(t.adf.children.length, 1);
assert.strictEqual(t.adf.children[0].name, 'EF.MY-IMSI');
assert.strictEqual(t.adf.children[0].customPath, 'ADF.USIM/6F07');
});
test('injection renames and marks an existing model node', () => {
const t = tree();
pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }];
pysimCustomInject(t.dfC);
assert.strictEqual(t.dfC.children.length, 1);
assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED');
assert.strictEqual(t.dfC.children[0].custom, true);
});
test('injected DFs are directories and can host their own children', () => {
const t = tree();
pysimCustomFiles = [
{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' },
{ path: 'MF/5F10/6F46', name: 'EF.UNDER-NEW', kind: 'ef' },
];
pysimCustomInject(t.mf);
const df = t.mf.children.find(c => c.fid === '5F10');
assert.ok(df, 'custom DF injected into MF');
assert.strictEqual(df.isDir, true);
assert.strictEqual(pysimFsNodePath(df), 'MF/5F10');
pysimCustomInject(df);
assert.strictEqual(df.children.length, 1);
assert.strictEqual(df.children[0].name, 'EF.UNDER-NEW');
});
test('refresh restores renamed model nodes and drops injected ones', () => {
const t = tree();
pysimFsTreeRoot = t.mf;
pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }];
pysimCustomRefreshTree();
assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED');
assert.strictEqual(t.dfC.children[0].custom, true);
// deleting the entry brings the model name back
pysimCustomFiles = [];
pysimCustomRefreshTree();
assert.strictEqual(t.dfC.children[0].name, 'EF.X');
assert.ok(!t.dfC.children[0].custom);
assert.strictEqual(t.dfC.children[0].modelName, undefined);
// injected nodes disappear together with their entry
pysimCustomFiles = [{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' }];
pysimCustomRefreshTree();
assert.ok(t.mf.children.some(c => c.fid === '5F10'));
pysimCustomFiles = [];
pysimCustomRefreshTree();
assert.ok(!t.mf.children.some(c => c.fid === '5F10'));
});
+70
View File
@@ -0,0 +1,70 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractBlock(startMarker, endMarker) {
const start = html.indexOf(startMarker);
const end = html.indexOf(endMarker, start);
if (start < 0 || end < 0) throw new Error('block not found');
return html.slice(start, end);
}
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'cmdQualifierShort'));
eval(extractBlock('const REJECTION_CAUSES = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
test('CMD_NAMES decodes timer management and the BIP commands', () => {
assert.strictEqual(CMD_NAMES['27'], 'TIMER MANAGEMENT');
assert.strictEqual(CMD_NAMES['40'], 'OPEN CHANNEL');
assert.strictEqual(CMD_NAMES['41'], 'CLOSE CHANNEL');
assert.strictEqual(CMD_NAMES['42'], 'RECEIVE DATA');
assert.strictEqual(CMD_NAMES['43'], 'SEND DATA');
assert.strictEqual(CMD_NAMES['44'], 'GET CHANNEL STATUS');
});
test('cmdQualifierShort decodes TIMER MANAGEMENT actions', () => {
assert.strictEqual(cmdQualifierShort('27', 0x00), 'Start');
assert.strictEqual(cmdQualifierShort('27', 0x01), 'Deactivate');
assert.strictEqual(cmdQualifierShort('27', 0x02), 'Get');
});
test('cmdQualifierShort decodes OPEN CHANNEL qualifier flags', () => {
assert.strictEqual(cmdQualifierShort('40', 0x00), 'OnDemand');
assert.strictEqual(cmdQualifierShort('40', 0x01), 'Immediate');
assert.strictEqual(cmdQualifierShort('40', 0x03), 'Immediate+AutoReconn');
assert.strictEqual(cmdQualifierShort('40', 0x05), 'Background');
assert.strictEqual(cmdQualifierShort('40', 0x0C), 'Background+DNS');
});
test('cmdQualifierShort returns empty for unknown types', () => {
assert.strictEqual(cmdQualifierShort('99', 0x01), '');
});
test('channel status event builds the B8 channel status TLV', () => {
const build = EVENT_FORMS[0x0A].build;
assert.strictEqual(EVENT_FORMS[0x0A].note, undefined);
assert.strictEqual(build({ channel: '2', state: '128', info: '5' }), 'B8028205');
assert.strictEqual(build({ channel: '1', state: '0', info: '0' }), 'B8020100');
assert.strictEqual(build({ channel: '0', state: '64', info: '0' }), 'B8024000');
});
+103
View File
@@ -0,0 +1,103 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
code += extractFunc(html, 'getParentSel') + '\n';
code += extractFunc(html, 'getParentPath') + '\n';
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
code += 'globalThis.pysimCustomInject = () => {};\n';
eval(code);
let calls = [];
let responses = [];
let renders = 0;
function setup() {
calls = [];
responses = [];
renders = 0;
globalThis.pysimFetch = async (p, body) => {
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
const r = responses.shift();
if (r instanceof Error) throw r;
return JSON.parse(JSON.stringify(r));
};
globalThis.pysimFsRenderTree = () => { renders++; };
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
return node;
}
test('tree error payload marks the directory as absent', async () => {
const node = setup();
responses = [
{ success: false, error: 'SW ... 6a82', exists: false },
{ success: false, error: 'SW ... 6a82', exists: false },
];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(renders, 1);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].body.parent_sel, 'MF');
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
});
test('error payload without exists is not treated as an empty listing', async () => {
const node = setup();
responses = [
{ success: false, error: 'boom' },
{ success: false, error: 'boom' },
];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(renders, 1);
assert.strictEqual(calls.length, 1);
});
test('a 200 exists:false response marks the directory absent without retrying', async () => {
const node = setup();
responses = [{ exists: false }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(calls.length, 1);
});
test('a node with loaded children is not fetched again', async () => {
const node = setup();
node.exists = true;
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, true);
assert.strictEqual(node.children.length, 1);
assert.strictEqual(calls.length, 0);
});
test('empty successful listing keeps the directory present', async () => {
const node = setup();
responses = [{ exists: true, children: [] }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, true);
assert.deepStrictEqual(node.children, []);
});
+156
View File
@@ -0,0 +1,156 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsFitTree', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
code += extractFunc(html, fn) + '\n';
}
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
eval(code);
function fakeEl() {
const classes = new Set();
return {
textContent: '',
attrs: {},
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
},
setAttribute(k, v) { this.attrs[k] = v; },
};
}
let els = {};
let calls = [];
function setup(routes) {
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
calls = [];
globalThis.document = { getElementById: id => els[id] || null };
globalThis.pysimFetch = async (p, body) => {
calls.push({ path: p, body: body || {} });
for (const r of routes) {
if (r.path !== p) continue;
if (r.name && (!body || body.name !== r.name)) continue;
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
}
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
};
globalThis.pysimFsRenderTree = () => {};
}
function root(children) {
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
return pysimFsTreeRoot;
}
const df = (name, fid) => ({ name, fid, isDir: true });
const ef = (name, fid) => ({ name, fid, isDir: false });
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
test('probes dirs and files, including custom entries, and reports counts', async () => {
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
let sawStop = null;
setup([
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
]);
await pysimFsProbeAll();
assert.strictEqual(sawStop, 'Stop');
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /4\/4 files/);
assert.match(status, /3 present/);
assert.match(status, /1 absent/);
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
});
test('an absent directory is marked and its subtree is never fetched', async () => {
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
setup([
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
]);
await pysimFsProbeAll();
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /2\/2 files/);
assert.match(status, /0 present/);
assert.match(status, /2 absent/);
});
test('stop halts the walk and still reports a summary', async () => {
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
setup([
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
]);
await pysimFsProbeAll();
assert.deepStrictEqual(selectNames(), ['EF.X']);
const status = els['pysim-fs-probe-status'].textContent;
assert.ok(status.startsWith('Stopped —'), status);
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
});
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
assert.deepStrictEqual(body.parent_path, ['MF']);
assert.strictEqual(body.parent_sel, 'MF');
assert.ok(!body.allow_probe);
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
assert.deepStrictEqual(custom.parent_path, ['MF']);
assert.strictEqual(custom.allow_probe, true);
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
});
test('children of an absent directory are neither fetched nor selected', async () => {
const stale = Object.assign(df('DF.C', '5f03'), {
exists: false,
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
});
root([stale, ef('EF.ROOT', '2f01')]);
setup([
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
]);
await pysimFsProbeAll();
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
assert.strictEqual(stale.exists, false);
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /2\/2 files/);
assert.match(status, /1 present/);
assert.match(status, /1 absent/);
});
+67
View File
@@ -0,0 +1,67 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimFsSort = "fid";\n';
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
test('absent directory renders a cross and no expand toggle', () => {
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('✗'), out);
assert.ok(!out.includes('pysimFsToggleDir'), out);
assert.ok(!out.includes('▶'), out);
});
test('expanded empty directory shows the (empty) placeholder', () => {
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('(empty)'), out);
});
test('expanded directory with children renders no placeholder', () => {
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('EF.IMSI'), out);
assert.ok(!out.includes('(empty)'), out);
});
test('collapsed directory hides its children', () => {
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
const out = pysimFsRenderNode(node, 0);
assert.ok(!out.includes('EF.IMSI'), out);
});
test('non-existing directory hides previously loaded children', () => {
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('✗'), out);
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
assert.ok(!out.includes('(empty)'), out);
assert.ok(!out.includes('pysimFsToggleDir'), out);
});
+66
View File
@@ -0,0 +1,66 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
eval(extractFunc(html, 'pysimFsSortChildren'));
const f = (fid, name) => ({ fid, name, isDir: false });
const d = (fid, name) => ({ fid, name, isDir: true });
test('DFs sort before EFs in both modes', () => {
const children = [f('6F07', 'EF.IMSI'), d('7F20', 'DF.GSM'), f('2FE2', 'EF.ICCID'), d('7F10', 'DF.TELECOM')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['7F10', '7F20', '2FE2', '6F07']);
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['DF.GSM', 'DF.TELECOM', 'EF.ICCID', 'EF.IMSI']);
});
test('FID mode sorts EFs numerically by FID string', () => {
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'EF.ICCID'), f('6F07', 'EF.IMSI')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07', '6F3A']);
});
test('name mode sorts case-insensitively', () => {
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'ef.iccid'), f('6F07', 'EF.IMSI')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['EF.ADN', 'ef.iccid', 'EF.IMSI']);
});
test('missing name falls back to the FID as the sort key', () => {
const children = [f('2FE2', null), f('6F07', 'EF.IMSI'), f('6F3A', '')];
// keys: '2FE2', 'EF.IMSI', '6F3A' -> '2FE2' < '6F3A' < 'EF.IMSI'
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F3A', '6F07']);
});
test('custom entries use their isDir flag for the DF priority', () => {
const children = [f('6F3A', 'EF.ADN'), d('7F20', 'DF.GSM')];
assert.strictEqual(pysimFsSortChildren(children, 'fid')[0].name, 'DF.GSM');
});
test('equal keys keep a deterministic tie-break by the other field', () => {
const children = [f('6F07', 'EF.SAME'), f('2FE2', 'EF.SAME')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F07']);
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07']);
});
test('does not mutate the input array', () => {
const children = [f('6F3A', 'B'), f('2FE2', 'A')];
pysimFsSortChildren(children, 'fid');
assert.deepStrictEqual(children.map(x => x.fid), ['6F3A', '2FE2']);
});
+106 -1
View File
@@ -13,10 +13,40 @@ test('HTML <div> tags are balanced', () => {
test('top-level tabs match the rearranged views', () => {
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'pysim', 'profiler', 'phone']);
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', 'cards', 'profiler', 'pysim', 'phone']);
assert.match(html, /data-tab="c-apdu">Remote APDU</);
});
test('cards list shows the SCP81 PSK column with blue/red row buttons', () => {
assert.match(html, /data-l10n="SCP81">SCP81</);
const fn = /function cardsRender\(\)[\s\S]*?\n\}/.exec(html);
assert.ok(fn, 'cardsRender not found');
assert.match(fn[0], /cardsEdit\(' \+ i \+ '\)" class="[^"]*bg-blue-600 text-white/);
assert.match(fn[0], /cardsRemove\(' \+ i \+ '\)" class="[^"]*bg-red-600 text-white/);
});
test('PLI qualifier tables cover all standard qualifiers', () => {
// ESN (07), MEID (0B) and Supported RATs (1A) must at least be named, in
// both the TR Config dictionary and the proactive-log short labels.
const pli = /const PLI_QUALIFIERS = \[([\s\S]*?)\];/.exec(html);
assert.ok(pli, 'PLI_QUALIFIERS not found');
for (const code of ['07', '0B', '1A']) {
assert.ok(pli[1].includes("{code:'" + code + "'"), 'PLI_QUALIFIERS missing ' + code);
}
const block = /const CMD_QUALIFIER_SHORT = \{([\s\S]*?)\n\};/.exec(html);
assert.ok(block, 'CMD_QUALIFIER_SHORT not found');
const short = /'26': \{([^}]*)\}/.exec(block[1]);
assert.ok(short, "CMD_QUALIFIER_SHORT['26'] not found");
for (const key of ['0x07', '0x0B', '0x1A']) {
assert.ok(short[1].includes(key + ':'), 'CMD_QUALIFIER_SHORT 26 missing ' + key);
}
});
test('profile rows have a Clone action', () => {
assert.match(html, /onclick="profilerClone\(' \+ i \+ '\)"/);
assert.match(html, /t\('Clone'\)/);
});
test('response parser is a Remote APDU pill', () => {
assert.match(html, /data-sub="response" onclick="cApduSwitchSubtab\('response'\)"/);
assert.ok(html.includes('id="c-apdu-sub-response"'));
@@ -41,3 +71,78 @@ test('scan name input starts scanning on Enter', () => {
test('snapshot view has a timing summary block', () => {
assert.ok(html.includes('id="snapshot-summary"'));
});
test('header state indicator and profiler custom-files tab', () => {
assert.ok(html.includes('id="state-indicator"'));
assert.ok(html.includes('id="profiler-list-custom"'));
assert.ok(html.includes('data-list-tab="custom"'));
assert.ok(!html.includes('data-pysim-sub="custom"'));
});
test('header status indicator has a compact ADM badge', () => {
assert.ok(html.includes('id="state-indicator-adm"'));
});
test('file manager has FID / Name sort pills', () => {
assert.match(html, /data-fs-sort="fid" onclick="pysimFsSetSort\('fid'\)"/);
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
assert.ok(html.includes('pysim-fs-sort-pill'));
});
test('file manager keeps sort/probe controls above the scrolling tree', () => {
// The sort pills and the Probe all files button/status must sit outside
// the scrolling tree container so they stay visible while it scrolls.
assert.ok(html.indexOf('id="pysim-fs-probe-btn"') < html.indexOf('id="pysim-fs-tree"'));
assert.ok(html.indexOf('pysim-fs-sort-pill') < html.indexOf('id="pysim-fs-tree"'));
assert.match(html, /style="max-height:65vh"[^>]*>\s*<div id="pysim-fs-tree">/);
// the runtime fit caps it to the free viewport space; 65vh stays only as
// the no-JS fallback
assert.match(html, /function pysimFsFitTree\(/);
assert.match(html, /addEventListener\('resize', pysimFsFitTree\)/);
});
test('custom-files init runs after the language init', () => {
// pysimCustomLoad/RenderParents call t(): running them before
// currentLang is initialized throws a TDZ error and aborts the rest
// of the script (all later handlers fail with 'before initialization').
assert.ok(html.indexOf('// Init custom files') > html.indexOf("let currentLang = 'en';"));
});
test('custom files form has add/save and cancel controls', () => {
assert.match(html, /id="pysim-cf-add-btn"[^>]*data-l10n="Add"/);
assert.match(html, /id="pysim-cf-cancel-btn"[^>]*class="hidden[^"]*"[^>]*data-l10n="Cancel"/);
// canonical path form: root + parent DF + 4-hex FID, no free-form path
assert.ok(html.includes('id="pysim-cf-root"'));
assert.ok(html.includes('id="pysim-cf-parent"'));
assert.ok(html.includes('id="pysim-cf-fid"'));
assert.ok(!html.includes('id="pysim-cf-path"'));
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
assert.ok(!html.includes('pysimCustomAdd'));
});
test('file manager has a probe-all-files button and status line', () => {
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
assert.ok(html.includes('id="pysim-fs-probe-status"'));
});
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
const detail = html.indexOf('id="pysim-fs-detail"');
const info = html.indexOf('id="pysim-fs-info"');
const content = html.indexOf('id="pysim-fs-content"');
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
assert.ok(html.includes('function pysimFsInfoHtml'));
assert.ok(html.includes("pysimFsInfoHtml(sel)"));
assert.ok(!html.includes('pysim-fs-filename'));
assert.ok(html.includes('let pysimFsSelected = null;'));
assert.ok(html.includes('pysimFsSelected = name;'));
assert.ok(!html.includes('pysimFsSelect()'));
});
test('profile list has a Profile from snapshot button', () => {
assert.match(html, /data-l10n="Profile from snapshot">Profile from snapshot</);
assert.ok(html.includes('onclick="profilerFromSnapshot()"'));
assert.ok(html.includes('function profilerScanFromSnapshot(si)'));
assert.ok(html.includes('function profilerBuildFileRuleFromSnapshot('));
});
+136
View File
@@ -0,0 +1,136 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
code += 'globalThis.t = s => s;\n';
eval(code);
function fakeEl() {
const classes = new Set();
return {
classes,
attrs: {},
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
},
setAttribute(k, v) { this.attrs[k] = v; },
removeAttribute(k) { delete this.attrs[k]; },
};
}
function setup() {
const els = {
'state-indicator': fakeEl(),
'state-indicator-dot': fakeEl(),
'state-indicator-img': fakeEl(),
};
els['state-indicator-img'].src = '';
globalThis.document = { getElementById: id => els[id] || null };
_pysimServerAvailable = null;
_pysimCardEquipped = false;
_pysimEquipping = false;
return els;
}
test('unprobed server shows a gray dot and a Connecting title', () => {
const els = setup();
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
assert.ok(dot.classes.has('text-gray-400'));
assert.ok(!dot.classes.has('hidden'));
assert.ok(img.classes.has('hidden'));
assert.strictEqual(wrap.attrs.title, 'Connecting...');
assert.strictEqual(dot.attrs.title, 'Connecting...');
});
test('unreachable server shows a red dot', () => {
const els = setup();
_pysimServerAvailable = false;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
assert.ok(dot.classes.has('text-red-500'));
assert.ok(!dot.classes.has('text-gray-400'));
assert.ok(img.classes.has('hidden'));
assert.strictEqual(wrap.attrs.title, 'No server connection');
assert.strictEqual(dot.attrs.title, 'No server connection');
});
test('server up without a card shows nosim.svg', () => {
const els = setup();
_pysimServerAvailable = true;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
assert.ok(dot.classes.has('hidden'));
assert.ok(!img.classes.has('hidden'));
assert.strictEqual(img.src, 'nosim.svg');
assert.strictEqual(wrap.attrs.title, 'Server connected, no card equipped');
assert.strictEqual(dot.attrs.title, undefined);
});
test('equipped card shows sim.svg', () => {
const els = setup();
_pysimServerAvailable = true;
_pysimCardEquipped = true;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
assert.strictEqual(img.src, 'sim.svg');
assert.strictEqual(wrap.attrs.title, 'Card equipped');
});
test('equipping shows the animated sim_anim.svg', () => {
const els = setup();
_pysimServerAvailable = true;
_pysimEquipping = true;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
assert.strictEqual(img.src, 'sim_anim.svg');
assert.strictEqual(wrap.attrs.title, 'Card inserted — initializing...');
});
test('dot color transitions do not accumulate', () => {
const els = setup();
_pysimServerAvailable = false;
pysimUpdateStateIndicator();
_pysimServerAvailable = null;
pysimUpdateStateIndicator();
const dot = els['state-indicator-dot'];
assert.ok(dot.classes.has('text-gray-400'));
assert.ok(!dot.classes.has('text-red-500'));
});
test('indicator markup carries the dot and image elements', () => {
assert.match(html, /id="state-indicator-dot"/);
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
});
test('indicator image stays within the 32px header row budget', () => {
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
assert.ok(m, 'inline image size not found');
assert.strictEqual(m[1], m[2]);
const size = Number(m[1]);
assert.ok(size >= 24 && size <= 32, 'size ' + size + 'px would change the header height');
});
+4 -2
View File
@@ -27,7 +27,8 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
'globalThis.pysimEventsRender = () => { globalThis._events = (globalThis._events || 0) + 1; };\n' +
'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' +
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n';
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n' +
'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n';
eval(code);
function makeClassList() {
@@ -52,7 +53,7 @@ function setup() {
getElementById: id => panels[id] || null,
};
globalThis._anchor = null;
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = 0;
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
return { buttons, panels };
}
@@ -80,4 +81,5 @@ test('Phone pill shows the phone panel and renders CAT views', () => {
assert.strictEqual(globalThis._log, 1);
assert.strictEqual(globalThis._poll, 1);
assert.strictEqual(globalThis._pli, 0);
assert.strictEqual(globalThis._tp, 1);
});
+320 -35
View File
@@ -21,14 +21,18 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel'];
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
code += extractFunc(html, 'profilerRunRule', true) + '\n';
code += extractFunc(html, 'profilerScanCard', true) + '\n';
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
code += "var _scanTarget = 'profile';\n";
code += extractFunc(html, 'profilerCardIccid', true) + '\n';
code += extractFunc(html, 'profilerCheck', true) + '\n';
code += extractFunc(html, 'profilerCheckSnapshot', true) + '\n';
code += extractFunc(html, 'profilerScanSnapshot', true) + '\n';
code += "var _scanTarget = 'profile';\nvar profilerResults = null;\nvar profilerResultsHeader = null;\nvar profilerMismatchOnly = false;\n";
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
eval(code);
@@ -431,6 +435,63 @@ test('profilerNumRanges compresses consecutive record numbers', () => {
assert.strictEqual(profilerNumRanges([6]), '6');
});
test('profilerMatchedRecordsText shows the count and prefixed record numbers', () => {
global.t = s => s;
assert.strictEqual(profilerMatchedRecordsText([8], 8), '1 of 8 (#8)');
assert.strictEqual(profilerMatchedRecordsText([1, 2, 3, 5], 8), '4 of 8 (#1\u2013#3, #5)');
assert.strictEqual(profilerMatchedRecordsText([1, 2, 3, 4, 5, 6, 7, 8, 10], 10),
'9 of 10 (#1\u2013#8, #10)');
// fallback for fixtures without a total: the count itself
assert.strictEqual(profilerMatchedRecordsText([2, 4], undefined), '2 of 2 (#2, #4)');
assert.strictEqual(profilerMatchedRecordsText([], 8), '');
delete global.t;
});
test('profilerCloneName makes a unique "Copy of" name', () => {
global.t = s => s;
assert.strictEqual(profilerCloneName('Foobar', []), 'Copy of Foobar');
assert.strictEqual(profilerCloneName('Foobar', ['Copy of Foobar']), 'Copy of Foobar (2)');
assert.strictEqual(profilerCloneName('Foobar', ['Copy of Foobar', 'Copy of Foobar (2)']), 'Copy of Foobar (3)');
assert.strictEqual(profilerCloneName('', []), 'Copy of ');
delete global.t;
});
test('profilerClone inserts a deep copy and opens the editor on it', () => {
global.t = s => s;
const original = {
id: 'orig-1', name: 'Foobar', created: '2020-01-01T00:00:00.000Z',
rules: [{ type: 'file', path: 'MF/3F00/2FE2', fciMode: 'exact',
content: { mode: 'exact', kind: 'transparent', expected: 'AA' } }],
};
global.profiles = [original, { id: 'orig-2', name: 'Other', created: '2020-01-01T00:00:00.000Z', rules: [] }];
let saved = 0, rendered = 0, editedWith = -1;
global.profilerSave = () => { saved++; };
global.profilerRenderList = () => { rendered++; };
global.profilerEdit = i => { editedWith = i; };
try {
profilerClone(0);
assert.strictEqual(global.profiles.length, 3);
const copy = global.profiles[1];
assert.strictEqual(copy.name, 'Copy of Foobar');
assert.notStrictEqual(copy.id, original.id);
assert.ok(copy.created > original.created);
assert.deepStrictEqual(copy.rules, original.rules);
// deep copy: editing the clone's rule leaves the original untouched
copy.rules[0].content.expected = 'BB';
assert.strictEqual(original.rules[0].content.expected, 'AA');
assert.strictEqual(global.profiles[2].name, 'Other');
assert.strictEqual(saved, 1);
assert.strictEqual(rendered, 1);
assert.strictEqual(editedWith, 1);
// a second clone of the same profile gets the (2) suffix
profilerClone(0);
assert.strictEqual(global.profiles[1].name, 'Copy of Foobar (2)');
} finally {
delete global.t; delete global.profiles;
delete global.profilerSave; delete global.profilerRenderList; delete global.profilerEdit;
}
});
test('profilerResultAspects groups checks and lets Exact FCI subsume type/size', () => {
assert.deepStrictEqual(
profilerResultAspects({ checks: [
@@ -507,7 +568,7 @@ test('record count mismatch is reported once when numRecords is checked', async
global.t = s => s;
global.pysimCustomFiles = [];
const report = profilerRenderReport([res]);
assert.ok(report.includes('matching records: 1-10'), report);
assert.ok(report.includes('matching records: 10 of 30 (#1\u2013#10)'), report);
delete global.t;
});
@@ -549,12 +610,12 @@ test('profilerRenderReport includes the checked-aspects summary and matching-rec
{ path: 'MF/7F20/6F4E', name: 'EF.Y', status: 'fail', checks: [
{ label: 'content.rec6', ok: false, expected: 'BB', actual: 'XX' },
{ label: 'content.rec1', ok: true }, { label: 'content.rec2', ok: true }, { label: 'content.rec5', ok: true },
], recordsMatched: [1, 2, 5] },
], recordsMatched: [1, 2, 5], recordsTotal: 6 },
]);
assert.ok(html.includes('filetype and size, contents'));
assert.ok(html.includes('filetype ✓, size ✗, contents ✓'));
assert.ok(html.includes('matching records'));
assert.ok(html.includes('1-2, 5'));
assert.ok(html.includes('3 of 6 (#1\u2013#2, #5)'));
delete global.t;
});
@@ -698,6 +759,33 @@ test('fcpDiffHtml highlights differing FCI parameters', () => {
delete global.t;
});
test('pysimFsInfoHtml shows FID, type, size and the decoded FCI', () => {
global.t = s => s;
const hex = '621A82054221000F0583026F4F8A01058B036F06098002004B8801B0';
const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75, record_len: 15, num_of_rec: 5, fci_hex: hex });
assert.ok(out.includes('FID: 6F4F'), out);
assert.ok(out.includes('File type: linear_fixed'), out);
assert.ok(out.includes('Size: 75'), out);
assert.ok(out.includes('Record length: 15'), out);
assert.ok(out.includes('Record count: 5'), out);
assert.ok(!out.includes('Decoded FCI'), out);
assert.ok(out.includes('File descriptor'), out);
assert.ok(out.includes('75 bytes'), out);
delete global.t;
});
test('pysimFsInfoHtml omits the FCI block without fci_hex and skips null fields', () => {
global.t = s => s;
const out = pysimFsInfoHtml({ fid: '6f07', file_type: 'transparent', file_size: null, record_len: null, num_of_rec: undefined, fci_hex: null });
assert.ok(out.includes('FID: 6F07'), out);
assert.ok(!out.includes('Size:'), out);
assert.ok(!out.includes('Record length:'), out);
assert.ok(!out.includes('Record count:'), out);
assert.ok(!out.includes('Decoded FCI'), out);
assert.strictEqual(pysimFsInfoHtml(null), '');
delete global.t;
});
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
global.t = s => s;
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
@@ -916,17 +1004,46 @@ test('profilerScanCard snapshot mode builds snapshot entries with ICCID', async
delete global.pysimCustomFiles;
});
test('profilerScanCard walks nested dirs with their parent path, not their own segment', async () => {
global.pysimCustomFiles = [];
const treeCalls = [];
global.pysimFetch = async (path, body) => {
if (path === '/api/tree') {
treeCalls.push(body);
if (body.name === 'MF') return { exists: true, name: 'MF', children: [
{ name: 'EF.DIR', fid: '2f00', isDir: false },
{ name: 'DF.GSM', fid: '7f20', isDir: true },
] };
if (body.name === 'DF.GSM') return { exists: true, name: 'DF.GSM', children: [
{ name: 'EF.ADN', fid: '6f3a', isDir: false },
] };
throw new Error('unexpected tree ' + body.name);
}
if (path === '/api/select') return { name: 'X', fid: '0000', file_type: 'transparent', file_size: 1, record_len: null, num_of_rec: null, exists: true };
if (path === '/api/read') return { success: true, data: 'AA' };
throw new Error('unexpected ' + path);
};
const files = await profilerScanCard(new Set(), new Set(), 'type', undefined, new Set(), 'snapshot');
// MF root has no parent; DF.GSM must be looked up under MF, not under itself
assert.deepStrictEqual(treeCalls.map(b => b.parent_path), [undefined, ['MF']]);
assert.deepStrictEqual(files.map(f => f.path).sort(), ['MF/2F00', 'MF/7F20/6F3A']);
delete global.pysimCustomFiles;
});
test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
const mkBtn = tab => ({
dataset: { listTab: tab },
classList: { _c: new Set(), toggle(c, on) { if (on) this._c.add(c); else this._c.delete(c); } },
});
const btns = [mkBtn('profiles'), mkBtn('snapshots')];
const btns = [mkBtn('profiles'), mkBtn('snapshots'), mkBtn('custom')];
const profilesEl = { hidden: false, classList: { toggle(cls, on) { profilesEl.hidden = on; } } };
const snapsEl = { hidden: true, classList: { toggle(cls, on) { snapsEl.hidden = on; } } };
const customEl = { hidden: true, classList: { toggle(cls, on) { customEl.hidden = on; } } };
global.pysimCustomRender = () => {};
global.setHelpAnchor = () => {};
global.document = {
querySelectorAll: sel => (sel === '.profiler-list-tab' ? btns : []),
getElementById: id => (id === 'profiler-list-profiles' ? profilesEl : id === 'profiler-list-snapshots' ? snapsEl : null),
getElementById: id => (id === 'profiler-list-profiles' ? profilesEl : id === 'profiler-list-snapshots' ? snapsEl : id === 'profiler-list-custom' ? customEl : null),
};
profilerListSwitch('snapshots');
@@ -941,7 +1058,14 @@ test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
assert.ok(btns[0].classList._c.has('bg-blue-600'));
assert.ok(btns[1].classList._c.has('bg-gray-200'));
profilerListSwitch('custom');
assert.strictEqual(customEl.hidden, false);
assert.strictEqual(profilesEl.hidden, true);
assert.strictEqual(snapsEl.hidden, true);
delete global.document;
delete global.pysimCustomRender;
delete global.setHelpAnchor;
});
test('profilerScanRefreshOptions re-translates mask labels without touching checkbox state', () => {
@@ -1049,7 +1173,7 @@ test('profilerRulesFromSnapshot builds exact-FCI rules from the master snapshot'
snapFile('MF/6F3A', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }),
snapFile('MF/6F3B', { content: null }),
]);
const rules = profilerRulesFromSnapshot(master, null);
const rules = profilerRulesFromSnapshot(master);
assert.strictEqual(rules.length, 3);
assert.strictEqual(rules[0].fciMode, 'exact');
assert.strictEqual(rules[0].fciHex, '621082024021');
@@ -1058,27 +1182,24 @@ test('profilerRulesFromSnapshot builds exact-FCI rules from the master snapshot'
assert.strictEqual(rules[2].content, null);
});
test('profilerRulesFromSnapshot masks only the checked FIDs', () => {
const master = masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', content: { kind: 'transparent', data: '082905911234567890' } }),
snapFile('MF/2FE2', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } }),
snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'AABBCCDD' } }),
]);
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
assert.deepStrictEqual(rules[0].content, { mode: 'mask', kind: 'transparent', expected: '08290591??????????' });
assert.deepStrictEqual(rules[1].content, { mode: 'exact', kind: 'transparent', expected: '98680012345678901234' });
assert.deepStrictEqual(rules[2].content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
});
test('profilerRulesFromSnapshot falls back to the symbolic name for masking', () => {
const master = masterSnap([snapFile('MF/CUSTOM1', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } })]);
const rules = profilerRulesFromSnapshot(master, new Set(['2FE2']));
assert.strictEqual(rules[0].content.mode, 'mask');
test('snapshot comparison is always exact (no IMSI/ICCID masking)', async () => {
const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })];
const rules = profilerRulesFromSnapshot(masterSnap(files));
assert.deepStrictEqual(rules[0].content,
{ mode: 'exact', kind: 'transparent', expected: '082905911234567890' });
const same = profilerSnapshotSource(masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })]));
const other = profilerSnapshotSource(masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567891' } })]));
assert.strictEqual((await profilerRunRule(rules[0], same)).status, 'pass');
const res = await profilerRunRule(rules[0], other);
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
});
test('snapshot comparison passes on an identical snapshot', async () => {
const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })];
const rules = profilerRulesFromSnapshot(masterSnap(files), new Set(['6F07']));
const rules = profilerRulesFromSnapshot(masterSnap(files));
const source = profilerSnapshotSource(masterSnap(files.map(f => ({ ...f }))));
for (const r of rules) {
assert.strictEqual((await profilerRunRule(r, source)).status, 'pass');
@@ -1088,21 +1209,12 @@ test('snapshot comparison passes on an identical snapshot', async () => {
test('snapshot comparison fails on a contents difference', async () => {
const master = masterSnap([snapFile('MF/6F3A')]);
const check = masterSnap([snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'CCDD' } })]);
const rules = profilerRulesFromSnapshot(master, null);
const rules = profilerRulesFromSnapshot(master);
const res = await profilerRunRule(rules[0], profilerSnapshotSource(check));
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
});
test('snapshot comparison mask ignores only the first 4 bytes', async () => {
const master = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059112345678' } })]);
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
const same = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059199999999' } })]);
const other = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059912345678' } })]);
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(same))).status, 'pass');
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(other))).status, 'fail');
});
test('profilerExtraFileResults reports files missing from the master', () => {
const master = masterSnap([snapFile('MF/6F3A')]);
const check = masterSnap([snapFile('MF/6f3a'), snapFile('MF/6F3B')]);
@@ -1266,3 +1378,176 @@ test('fcpDiffHtml headers use custom labels', () => {
assert.ok(diff.includes('>Candidate<'), diff);
delete global.t;
});
test('profilerLabelText builds default, verbatim and prefixed labels', () => {
global.t = s => 't:' + s;
assert.strictEqual(profilerLabelText(null, 'expected'), 't:expected');
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'expected'), 'A');
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'actual'), 'B');
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B', prefix: true }, 'actual'), 't:actual (B)');
assert.strictEqual(profilerLabelText({ expected: '', prefix: true }, 'expected'), 't:expected');
delete global.t;
});
test('profilerRenderReport prefixes names in expected/actual labels', () => {
global.t = s => s;
global.pysimCustomFiles = [];
const html = profilerRenderReport([{
path: 'MF/6F3A', status: 'fail',
checks: [{ label: 'fileSize', expected: 4, actual: 9, ok: false }],
}], { expected: 'My profile', actual: 'Snap X', prefix: true });
assert.ok(html.includes('expected (My profile)'), html);
assert.ok(html.includes('actual (Snap X)'), html);
delete global.t;
delete global.pysimCustomFiles;
});
test('fcpDiffHtml uses prefixed labels in headers and decode notes', () => {
global.t = s => s;
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110', { expected: 'Prof', actual: 'Snap', prefix: true });
assert.ok(diff.includes('expected (Prof)'), diff);
assert.ok(diff.includes('actual (Snap)'), diff);
const bad = fcpDiffHtml('not hex', FCP_TRANSPARENT, { expected: 'Prof', actual: 'Snap', prefix: true });
assert.ok(bad.includes('expected (Prof)'), bad);
delete global.t;
});
test('profilerCardIccid reads and decodes MF/2FE2, null otherwise', async () => {
let body = null;
global.pysimFetch = async (path, b) => { body = [path, b]; return { success: true, data: '98103254769810325476' }; };
assert.strictEqual(await profilerCardIccid(), '89012345678901234567');
assert.deepStrictEqual(body, ['/api/read', { path: 'MF/2FE2', mode: 'raw' }]);
global.pysimFetch = async () => ({ success: false });
assert.strictEqual(await profilerCardIccid(), null);
global.pysimFetch = async () => { throw new Error('offline'); };
assert.strictEqual(await profilerCardIccid(), null);
delete global.pysimFetch;
});
test('profilerCheck titles with the card ICCID and passes prefixed labels', async () => {
global.t = s => s;
global.profiles = [{ name: 'MyProfile', rules: [] }];
global.pysimFetch = async () => ({ success: true, data: '98103254769810325476' });
let captured = null;
global.profilerRunProfile = async (...args) => { captured = args; };
await profilerCheck(0);
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'MyProfile', to: '89012345678901234567' });
assert.deepStrictEqual(captured[4], { expected: 'MyProfile', actual: '89012345678901234567', prefix: true });
delete global.profiles; delete global.pysimFetch; delete global.profilerRunProfile; delete global.t;
});
test('profilerCheckSnapshot passes the snapshot name as the actual label', async () => {
global.t = s => s;
global.profiles = [{ name: 'Prof', rules: [] }];
global.snapshots = [{ name: 'SnapX', files: [] }];
global.document = { getElementById: () => ({ classList: { add() {} } }) };
let captured = null;
global.profilerRunProfile = async (...args) => { captured = args; };
await profilerCheckSnapshot(0, 0);
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'Prof', to: 'SnapX' });
assert.deepStrictEqual(captured[4], { expected: 'Prof', actual: 'SnapX', prefix: true });
delete global.profiles; delete global.snapshots; delete global.profilerRunProfile; delete global.t;
});
test('profilerResultsHeaderText builds profile and snapshot headers', () => {
global.t = s => 't:' + s;
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: '8901' }), 't:Profile verification results for: Prof \u2192 8901');
assert.strictEqual(profilerResultsHeaderText({ kind: 'snapshot', from: 'Snap1', to: 'Snap2' }), 't:Snapshot comparison results: Snap1 \u2192 Snap2');
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: null }), 't:Profile verification results for: Prof');
assert.strictEqual(profilerResultsHeaderText(null), '');
delete global.t;
});
test('profilerRenderResultsView writes the built header into the title', () => {
global.t = s => s;
profilerResults = [];
profilerResultsHeader = { kind: 'profile', from: 'Prof', to: 'Snap' };
const els = {};
for (const id of ['profiler-summary', 'profiler-report', 'profiler-results-title']) els[id] = { innerHTML: '', textContent: '' };
global.document = { getElementById: id => els[id] || null };
profilerRenderResultsView();
assert.strictEqual(els['profiler-results-title'].textContent, 'Profile verification results for: Prof \u2192 Snap');
profilerResultsHeader = { kind: 'snapshot', from: 'A', to: 'B' };
profilerRenderResultsView();
assert.strictEqual(els['profiler-results-title'].textContent, 'Snapshot comparison results: A \u2192 B');
profilerResults = null;
profilerResultsHeader = null;
delete global.t;
});
test('profilerBuildFileRuleFromSnapshot builds a rule with exact contents', () => {
const f = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'transparent', fileSize: 4, recordLen: null, numRecords: null, fciHex: '620B', content: { kind: 'transparent', data: 'AABBCCDD' } };
const rule = profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type_size', new Set());
assert.strictEqual(rule.path, 'MF/7F10/6F3A');
assert.strictEqual(rule.name, 'EF.ADN');
assert.strictEqual(rule.fileType, 'transparent');
assert.strictEqual(rule.fileSize, 4);
assert.strictEqual(rule.fciMode, 'type_size');
assert.strictEqual(rule.fciHex, '620B');
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
});
test('profilerBuildFileRuleFromSnapshot masks the first 4 bytes on request', () => {
const f = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: '0891101234567890' } };
assert.deepStrictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set(['6F07'])).content,
{ mode: 'mask', kind: 'transparent', expected: '08911012????????' });
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set()).content.mode, 'exact');
});
test('profilerBuildFileRuleFromSnapshot honors the ignore list by FID and name', () => {
const f = { path: 'MF/7F20/6F52', name: 'EF.KcGPRS', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: 'AABB' } };
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(['6F52']), new Set(), 'type_size', new Set()).content, null);
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(['EF.KCGPRS']), 'type_size', new Set()).content, null);
});
test('profilerBuildFileRuleFromSnapshot keeps uncaptured and record contents', () => {
const noContent = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: null };
assert.strictEqual(profilerBuildFileRuleFromSnapshot(noContent, new Set(), new Set(), 'type_size', new Set()).content, null);
const records = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'linear_fixed', fileSize: 60, recordLen: 30, numRecords: 2, content: { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] } };
const rule = profilerBuildFileRuleFromSnapshot(records, new Set(), new Set(), 'exact', new Set());
assert.strictEqual(rule.fileSize, null);
assert.strictEqual(rule.recordLen, 30);
assert.strictEqual(rule.numRecords, 2);
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
});
test('profilerScanSnapshot walks snapshot files with progress', async () => {
const snapshot = { files: [
{ path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', content: null },
{ path: 'MF/2FE2', name: 'EF.ICCID', fileType: 'transparent', content: { kind: 'transparent', data: '9807' } },
] };
const progress = [];
const rules = await profilerScanSnapshot(snapshot, new Set(), new Set(), 'type_size', new Set(), (d, tt, p) => progress.push([d, tt, p]));
assert.strictEqual(rules.length, 2);
assert.deepStrictEqual(rules.map(r => r.path), ['MF/6F07', 'MF/2FE2']);
assert.deepStrictEqual(progress, [[0, 2, ''], [1, 2, 'MF/6F07'], [2, 2, 'MF/2FE2']]);
});
test('profilerSnapshotPickListHtml wires the chosen action per snapshot', () => {
global.t = s => s;
global.snapshots = [
{ name: 'SnapA', iccid: '8901', created: '2026-01-01T00:00:00Z', files: [{}, {}] },
{ name: 'SnapB', iccid: '', created: '2026-01-02T00:00:00Z', files: [] },
];
const html = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
assert.ok(html.includes('onclick="profilerScanFromSnapshot(0)"'), html);
assert.ok(html.includes('onclick="profilerScanFromSnapshot(1)"'), html);
assert.ok(html.includes('SnapA'), html);
assert.ok(html.includes('2 files'), html);
delete global.snapshots;
delete global.t;
});
test('profilerScanSetTarget labels the snapshot-sourced profile form', () => {
global.t = s => s;
const els = {};
for (const id of ['profiler-scan-title', 'profiler-scan-name-label', 'profiler-scan-options']) {
els[id] = { textContent: '', attrs: {}, classList: { set: new Set(), toggle(c, on) { if (on) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } }, setAttribute(k, v) { this.attrs[k] = v; } };
}
global.document = { getElementById: id => els[id] || null };
profilerScanSetTarget('profile-snapshot');
assert.strictEqual(els['profiler-scan-title'].textContent, 'Profile from snapshot');
assert.strictEqual(els['profiler-scan-name-label'].textContent, 'Profile name');
assert.ok(!els['profiler-scan-options'].classList.contains('hidden'));
delete global.t;
});
+161 -2
View File
@@ -6,7 +6,7 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
@@ -22,13 +22,18 @@ function extractFunc(src, name) {
}
// Extract chain builder functions and dependencies
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'chainInit', 'chainRamBuildRowHex'];
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute'];
let code = '';
for (const f of FNS) {
code += extractFunc(html, f) + '\n';
}
const m = html.match(/const _chains = \{\};/);
if (m) code += m[0].replace(/^const /, 'var ') + '\n';
const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/);
if (lc) code += lc[0].replace(/^const /, 'var ') + '\n';
eval(code);
code += 'var _ramCardIdx = null;\nvar _ramOpLast = null;\nvar _ramExplorerData = null;\n';
eval(code);
const els = {};
@@ -161,3 +166,157 @@ test('STORE DATA ram-enc P1 values 00/40/80/C0/E0', () => {
assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1);
}
});
test('ramRenderExploreHtml localizes every label and button', () => {
const seen = [];
global.t = s => { seen.push(s); return 'XX' + s; };
const out = ramRenderExploreHtml(
{ appCount: 5, freeNV: 100, freeV: 50 },
[{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }],
[{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }],
[{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }]
);
delete global.t;
assert.ok(out.includes('XXDelete'), out);
assert.ok(out.includes('XXDelete All'), out);
assert.ok(out.includes('XXApplications:'), out);
assert.ok(out.includes('XXFree NV:'), out);
assert.ok(out.includes('XXFree Volatile:'), out);
assert.ok(out.includes('XXAID:'), out);
assert.ok(out.includes('XXLifecycle:'), out);
assert.ok(out.includes('XXPrivileges:'), out);
assert.ok(out.includes('XXSD AID:'), out);
assert.ok(out.includes('XXImplicit sel:'), out);
assert.ok(out.includes('XXVersion:'), out);
assert.ok(seen.includes('Application / Instance AID:'));
assert.ok(seen.includes('Load File AID / Package AID:'));
assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:'));
assert.ok(!out.includes('data-l10n'), out);
});
test('ramFmtPrivileges uses the translated (none) placeholder', () => {
global.t = s => 'XX' + s;
assert.strictEqual(ramFmtPrivileges(''), 'XX(none)');
assert.strictEqual(ramFmtPrivileges('00'), 'XX(none)');
delete global.t;
});
function fakeClassList() {
const set = new Set();
return {
add: (...cs) => cs.forEach(c => set.add(c)),
remove: (...cs) => cs.forEach(c => set.delete(c)),
contains: c => set.has(c),
toggle: (c, on) => { if (on === undefined ? !set.has(c) : on) set.add(c); else set.delete(c); },
};
}
function fakeEl(id) {
return {
id,
value: '',
innerHTML: '',
textContent: '',
classList: fakeClassList(),
options: [],
appendChild(opt) { this.options.push(opt); },
};
}
function fakeRamDocument(ids) {
const els = {};
for (const id of ids) els[id] = fakeEl(id);
const sel = els['ram-card-sel'];
if (sel) {
Object.defineProperty(sel, 'innerHTML', {
get() { return this._html || ''; },
set(v) { this._html = v; this.value = ''; },
});
}
globalThis.document = {
getElementById: id => els[id] || null,
createElement: () => fakeEl('option'),
};
return els;
}
test('ramOpChanged clears the executed status only on a real op change', () => {
const els = fakeRamDocument(['ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
_ramOpLast = null;
els['ram-op'].value = 'explore';
ramOpChanged();
assert.ok(!els['ram-result'].classList.contains('hidden'));
els['ram-result'].classList.remove('hidden');
els['ram-steps'].classList.remove('hidden');
ramOpChanged();
assert.ok(!els['ram-result'].classList.contains('hidden'), 'same op must keep the result');
els['ram-op'].value = 'install-cap';
ramOpChanged();
assert.ok(els['ram-result'].classList.contains('hidden'));
assert.ok(els['ram-steps'].classList.contains('hidden'));
assert.ok(els['ram-explorer'].classList.contains('hidden'));
assert.ok(els['ram-progress'].classList.contains('hidden'));
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
});
test('ramRender keeps the selected card preset across rebuilds', () => {
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
_ramCardIdx = null;
_ramOpLast = 'explore';
els['ram-op'].value = 'explore';
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '');
els['ram-card-sel'].value = '1';
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '1');
els['ram-card-sel'].value = '';
_ramCardIdx = 2;
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '2');
globalThis.cards = [{ name: 'A' }];
_ramCardIdx = 2;
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '');
delete globalThis.cards;
});
test('ramApplyCard remembers a valid picked preset', () => {
globalThis.cards = [{ name: 'A' }, { name: 'B' }];
let applied = null;
globalThis.cardsApply = i => { applied = i; };
_ramCardIdx = null;
ramApplyCard('1');
assert.strictEqual(_ramCardIdx, 1);
assert.strictEqual(applied, '1');
ramApplyCard('');
assert.strictEqual(_ramCardIdx, 1, 'invalid pick must not forget the preset');
delete globalThis.cards;
delete globalThis.cardsApply;
});
test('ramExecute commits the dropdown selection before running', async () => {
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
globalThis.cards = [{ name: 'A' }];
globalThis.getRamSpParams = () => ({ kicKey: '11', kidKey: '22' });
let explored = false;
globalThis.ramExplore = async () => { explored = true; };
globalThis.alert = () => {};
_ramCardIdx = null;
els['ram-card-sel'].value = '0';
els['ram-op'].value = 'explore';
await ramExecute();
assert.strictEqual(_ramCardIdx, 0);
assert.ok(explored);
delete globalThis.cards;
delete globalThis.getRamSpParams;
delete globalThis.ramExplore;
delete globalThis.alert;
});
test('ramCardIdxAfterRemove keeps the remembered index aligned', () => {
assert.strictEqual(ramCardIdxAfterRemove(2, 0), 1);
assert.strictEqual(ramCardIdxAfterRemove(0, 0), null);
assert.strictEqual(ramCardIdxAfterRemove(0, 2), 0);
assert.strictEqual(ramCardIdxAfterRemove(null, 1), null);
});
+131
View File
@@ -0,0 +1,131 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
eval(extractFunc(html, 'scp81LogLine'));
test('scp81LogLine renders a BIP open entry', () => {
assert.strictEqual(
scp81LogLine({ seq: 4, kind: 'open', channel: 1, requested: '77.221.153.19:10174', target: '127.0.0.1:8443' }),
'4 open ch1 77.221.153.19:10174 -> 127.0.0.1:8443');
});
test('scp81LogLine renders a TLS request with the GP headers', () => {
assert.strictEqual(
scp81LogLine({ seq: 5, kind: 'tls-request', method: 'POST', uri: '/server/adminagent?cmd=1', agent: '0123456789', bytes: 0 }),
'5 tls-request from=0123456789 POST /server/adminagent?cmd=1');
});
test('scp81LogLine renders handshake and errors', () => {
assert.strictEqual(
scp81LogLine({ seq: 6, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256', identity: 'id-1' }),
'6 tls-handshake id=id-1 PSK-AES128-CBC-SHA256');
assert.strictEqual(scp81LogLine({ seq: 7, kind: 'tls-error', error: 'boom' }), '7 tls-error boom');
});
test('scp81 log covers the dump mode kinds', () => {
assert.strictEqual(scp81LogLine({ seq: 1, kind: 'dump-rx', bytes: 71 }), '1 dump-rx 71B');
});
test('scp81LogLine renders script entries', () => {
assert.strictEqual(
scp81LogLine({ seq: 9, kind: 'script-send', index: 1, apdu: '80CAFF2100' }),
'9 script-send #1 80CAFF2100');
assert.strictEqual(
scp81LogLine({ seq: 12, kind: 'script-rapdu', index: 1, sw: '9000', bytes: 14 }),
'12 script-rapdu #1 SW 9000 14B');
assert.strictEqual(
scp81LogLine({ seq: 13, kind: 'script-memory', applets: 4, free_nv: 61600, free_volatile: 2048 }),
'13 script-memory applets=4 free NV=61600 free vol=2048');
});
eval(extractFunc(html, 'scp81DecodeGetStatus'));
eval(extractFunc(html, 'scp81GroupResults'));
eval(extractFunc(html, 'scp81ResultLines'));
test('scp81DecodeGetStatus decodes complete entries', () => {
const entries = scp81DecodeGetStatus('E32A4F08A0000000030000009F70010FC50380DE00C40BD276000005AAFFCAFE0010CC08A000000003000000');
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].aid, 'A000000003000000');
assert.strictEqual(entries[0].lifecycle, '0F');
assert.strictEqual(entries[0].privileges, '80DE00');
});
test('scp81DecodeGetStatus reads module AIDs and skips truncated tails', () => {
const entries = scp81DecodeGetStatus('E31B4F07A00000015153509F700101CE0201008408A000000151535041' + 'E3204F08D27600');
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].aid, 'A0000001515350');
assert.strictEqual(entries[0].modules[0], 'A000000151535041');
});
test('scp81GroupResults merges pages under one command', () => {
const groups = scp81GroupResults({ results: [
{ index: 4, apdu: '80F24002024F0000', sw: 'CAFE', rapdu: 'E3114F08A0000000030000009F70010FC50100' },
{ index: 5, apdu: '80F24002114F0F', sw: '9000', rapdu: 'E3114F08A0000000030000009F70010FC50100' },
{ index: 1, apdu: '80CAFF2100', sw: '9000', rapdu: 'FF210B81010D8202C5D683020962' },
] });
assert.strictEqual(groups.length, 2);
assert.strictEqual(groups[0].results.length, 2);
assert.strictEqual(groups[1].key, '80CAFF');
});
test('scp81ResultLines decodes the memory page', () => {
const lines = scp81ResultLines({ apdu: '80CAFF2100', results: [
{ rapdu: 'FF210B81010D8202C5D683020962', sw: '9000' } ] });
assert.strictEqual(lines[0], 'applets=13 free NV=50646 B free vol=2402 B');
});
test('scp81ResultLines decodes GET STATUS entries', () => {
global.decodePrivileges = () => 'Security Domain';
try {
const lines = scp81ResultLines({ apdu: '80F24002024F0000', results: [
{ rapdu: 'E3114F08A0000000030000009F70010FC50100', sw: '9000' } ] });
assert.strictEqual(lines[0], 'A000000003000000 life=0F [Security Domain]');
} finally {
delete global.decodePrivileges;
}
});
eval(extractFunc(html, 'scp81Ascii'));
eval(extractFunc(html, 'scp81Bcd'));
eval(extractFunc(html, 'scp81DecodeAdminParams'));
eval(extractFunc(html, 'scp81CmdLabel'));
test('scp81DecodeAdminParams decodes the stored 0085 answer', () => {
const hex = '856F84248103014003820281828500B50103B902058EC70403475042BC03020582BE05215BD50502851814383937303178787878787878787878787878787802400186070001250300100089248A096C6F63616C686F73748B1438393730317878787878787878787878787878788C012F';
const lines = scp81DecodeAdminParams(hex);
assert.ok(lines.includes('PSK id=89701xxxxxxxxxxxxxxx KVN/KID=40/01'));
assert.ok(lines.includes('retry counter=1 timer=00:10:00'));
assert.ok(lines.includes('host=localhost'));
assert.ok(lines.includes('agent=89701xxxxxxxxxxxxxxx'));
assert.ok(lines.includes('uri=/'));
assert.ok(lines.includes(' apn=GPB'));
assert.ok(lines.includes(' dest=91.213.5.2'));
});
test('scp81CmdLabel names the explore commands', () => {
assert.strictEqual(scp81CmdLabel('80CAFF2100'), 'GET DATA FF21 (extended card resources)');
assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (applications and security domains)');
assert.strictEqual(scp81CmdLabel('80F22002024F0000'), 'GET STATUS P1=20 (executable load files)');
assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (executable load files and modules)');
assert.strictEqual(scp81CmdLabel('80E8800000'), 'LOAD');
});
+142
View File
@@ -0,0 +1,142 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
// The extracted functions run in this module's scope; their free variables
// (`cards`, `t`, ...) resolve to globals we stub here.
global.t = (s) => s;
eval(extractFunc(html, 'cardsPskMap'));
eval(extractFunc(html, 'scriptsParseApdus'));
eval(extractFunc(html, 'scp81DeleteApdus'));
eval(extractFunc(html, 'scp81LogLine'));
eval(extractFunc(html, 'scp81LogEntryHtml'));
eval(extractFunc(html, 'scp81GroupResults'));
eval(extractFunc(html, 'scp81ScriptStateText'));
global.esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
test('explore template is the reference administration sequence', () => {
const m = /const SCP81_EXPLORE_APDUS = \[(.*?)\];/s.exec(html);
assert.ok(m, 'SCP81_EXPLORE_APDUS not found');
const apdus = [...m[1].matchAll(/'([0-9A-F]+)'/g)].map(x => x[1]);
assert.deepStrictEqual(apdus, ['80CAFF2100', '80F28002024F0000', '80CA008500',
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000']);
for (const a of apdus) {
assert.ok(/^[0-9A-F]+$/.test(a) && a.length % 2 === 0, a);
}
});
test('cardsPskMap keeps only cards with both identity and key', () => {
global.cards = [
{name: 'A', pskIdentity: 'id-1', pskKey: '00112233445566778899aabbccddeeff'},
{name: 'B', pskIdentity: 'id-2'}, // no key
{name: 'C', pskKey: '00112233'}, // no identity
{name: 'D', pskIdentity: 'id-4', pskKey: 'AA BB CC'}, // spaces stripped
{name: 'E', pskIdentity: 'id-5', pskKey: 'not-hex'},
];
assert.deepStrictEqual(cardsPskMap(), [
{identity: 'id-1', psk_hex: '00112233445566778899aabbccddeeff'},
{identity: 'id-4', psk_hex: 'AABBCC'},
]);
global.cards = [];
assert.deepStrictEqual(cardsPskMap(), []);
});
test('scriptsParseApdus accepts comments and whitespace, rejects bad lines', () => {
assert.deepStrictEqual(
scriptsParseApdus('80CAFF2100\n\n80F2 4002 024F 0000 # listing\n; note\n80E60200AB00'),
{apdus: ['80CAFF2100', '80F24002024F0000', '80E60200AB00']});
assert.strictEqual(scriptsParseApdus('80CAFF2100').error, undefined);
assert.deepStrictEqual(scriptsParseApdus('ZZ'), {error: 'ZZ'});
assert.deepStrictEqual(scriptsParseApdus('80CAF'), {error: '80CAF'});
assert.deepStrictEqual(scriptsParseApdus('80CAFF2'), {error: '80CAFF2'});
});
test('scp81DeleteApdus builds GP DELETE APDUs per AID', () => {
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000'], '00'),
['80E4000008A00000000300000000']);
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000', 'A000000100'], '80'),
['80E4800008A00000000300000000', '80E4800005A00000010000']);
assert.deepStrictEqual(scp81DeleteApdus([], '00'), []);
});
test('scp81LogEntryHtml marks matched and unknown PSK identities', () => {
global.cards = [{name: 'Foobar SIM', pskIdentity: 'id-1'}];
global.cardsPskName = (identity) =>
(global.cards.find(c => c.pskIdentity === identity) || {}).name || '';
const matched = scp81LogEntryHtml(
{seq: 1, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256',
identity: 'id-1', psk_match: true});
assert.match(matched, /id=id-1/);
assert.match(matched, /\[matched: Foobar SIM\]/);
const unknown = scp81LogEntryHtml(
{seq: 2, kind: 'tls-handshake', identity: 'who', psk_match: false});
assert.match(unknown, /\[unknown identity\]/);
const rejected = scp81LogEntryHtml({seq: 3, kind: 'tls-psk-unknown', identity: 'who'});
assert.match(rejected, /\[unknown identity\]/);
// non-handshake lines get no badge
const plain = scp81LogEntryHtml({seq: 4, kind: 'script-send', index: 1, apdu: '80CAFF2100'});
assert.doesNotMatch(plain, /\[\]/);
});
test('scp81ScriptStateText separates script progress from listing pages', () => {
// untouched / empty scripts show no state line
assert.strictEqual(scp81ScriptStateText({kind: 'none', total: 0}), '');
assert.strictEqual(scp81ScriptStateText({kind: 'Explore', total: 0}), '');
// still executing the configured APDUs
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3], script: []}),
'Explore: 4/6 executed');
// a configured APDU is awaiting the card's report
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4],
pending: {index: 9, pos: 5, page: false, apdu: '80F21002024F0000'}}),
'Explore: 5/6 executed · waiting for card');
// all script APDUs executed; only a listing page is in flight
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
pending: {index: 17, pos: null, page: true, apdu: '80F21003024F0000'},
pages: 11, complete: false}),
'Explore: 6/6 executed · listing pages (11)…');
// queued page, nothing sent yet
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
pending: null, pages: 11, pages_queued: 1, complete: false}),
'Explore: 6/6 executed · listing pages (11)…');
// everything drained
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
pending: null, pages: 11, pages_queued: 0, complete: true}),
'Explore: 6/6 executed · completed');
});
test('scp81GroupResults groups pages by originating command', () => {
const groups = scp81GroupResults({results: [
{index: 1, pos: 0, page: false, apdu: '80F24002024F0000', rapdu: 'E3', sw: 'CAFE'},
{index: 2, pos: null, page: true, apdu: '80F24003024F0000', rapdu: 'E3', sw: '9000'},
{index: 3, pos: 1, page: false, apdu: '80CAFF2100', rapdu: 'FF21', sw: '9000'},
]});
assert.strictEqual(groups.length, 2);
assert.strictEqual(groups[0].key, '80F240');
assert.strictEqual(groups[0].results.length, 2); // origin + continuation page
assert.strictEqual(groups[1].key, '80CAFF');
});
+13 -1
View File
@@ -28,7 +28,7 @@ function extractFunc(src, name) {
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
'aesCmac', 'genSp'];
'aesCmac', 'genSp', 'spNextCntr'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
@@ -217,3 +217,15 @@ test('AES rejects 8-byte key', () => {
});
assert.strictEqual(err, 'Error: AES KIc key must be 16, 24, or 32 bytes');
});
test('spNextCntr increments with carry', () => {
assert.strictEqual(spNextCntr('0000000001'), '0000000002');
assert.strictEqual(spNextCntr('00000000FF'), '0000000100');
assert.strictEqual(spNextCntr('000000FFFF'), '0000010000');
assert.strictEqual(spNextCntr('0000ABCDEF'), '0000ABCDF0');
});
test('spNextCntr tolerates lower case and separators', () => {
assert.strictEqual(spNextCntr('00000000 0a'), '000000000B');
assert.strictEqual(spNextCntr(''), '0000000001');
});
+85
View File
@@ -0,0 +1,85 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
code += 'globalThis.esc = s => s;\n';
eval(code);
function setup(response) {
const calls = { handled: null, rendered: 0 };
globalThis.pysimFetch = async () => response;
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
const btns = { classList: { add: () => {} } };
const back = { style: {} };
globalThis.document = {
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }),
};
return calls;
}
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back with a fetched DISPLAY TEXT shows it', async () => {
const data = { type: 'display_text', text: 'hello' };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [] };
const calls = setup(data);
await stkMenuRespond('timeout');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back answered with SW 9000 falls back to the cached top menu', async () => {
const calls = setup({ type: 'done', sw: '9000' });
await stkMenuRespond('back');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('cancel falls back to the cached top menu', async () => {
const calls = setup({ sw: '9000' });
await stkMenuRespond('cancel');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('ok navigates with the server response', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
const calls = setup(data);
await stkMenuRespond('ok');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
+145
View File
@@ -0,0 +1,145 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const f of ['tpNorm', 'tpValid', 'tpGetBit', 'tpSetBit', 'tpBitLabel', 'tpLayoutGroups']) {
code += extractFunc(html, f) + '\n';
}
code += html.match(/const TP_BITS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
code += html.match(/const TP_LAYOUT = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
code += html.match(/const TP_PRESETS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
eval(code);
test('tpNorm / tpValid normalize and validate profile hex', () => {
assert.strictEqual(tpNorm(' ff ee 00 '), 'FFEE00');
assert.strictEqual(tpNorm('zz'), '');
assert.ok(tpValid('FF'));
assert.ok(tpValid('00FF'));
assert.ok(!tpValid(''));
assert.ok(!tpValid('F'));
});
test('tpGetBit / tpSetBit address bits MSB-first per byte', () => {
const hex = '80' + '01'; // byte 1 b8 set, byte 2 b1 set
assert.strictEqual(tpGetBit(hex, 0), true); // byte 1 b8
assert.strictEqual(tpGetBit(hex, 7), false); // byte 1 b1
assert.strictEqual(tpGetBit(hex, 8), false); // byte 2 b8
assert.strictEqual(tpGetBit(hex, 15), true); // byte 2 b1
assert.strictEqual(tpGetBit(hex, 16), null); // beyond the profile
// toggling keeps the other bits untouched
assert.strictEqual(tpSetBit('00', 0, true), '80');
assert.strictEqual(tpSetBit('80', 0, false), '00');
assert.strictEqual(tpSetBit('FF', 7, false), 'FE');
// a bit beyond the current length grows the profile with zero bytes
assert.strictEqual(tpSetBit('FF', 24, true), 'FF000080');
});
test('TERMINAL PROFILE bit table matches the spec spot checks', () => {
assert.ok(TP_BITS.length >= 312, 'expected the full byte 1..39 table');
assert.strictEqual(TP_BITS.length % 8, 0);
assert.strictEqual(TP_BITS[0], 'Profile download'); // byte 1 b8
assert.strictEqual(TP_BITS[16], 'Proactive UICC: DISPLAY TEXT'); // byte 3 b8
assert.strictEqual(TP_BITS[32], 'Proactive UICC: SET UP EVENT LIST'); // byte 5 b8
assert.strictEqual(TP_BITS[42], 'Event: Data available'); // byte 6 b3
assert.strictEqual(TP_BITS[50], 'Proactive UICC: PERFORM CARD APDU'); // byte 7 b6 (pySim said RESET)
assert.strictEqual(TP_BITS[88], 'Proactive UICC: OPEN CHANNEL'); // byte 12 b8
assert.strictEqual(TP_BITS[140], 'Proactive UICC: PROVIDE LOCAL INFORMATION (ESN)'); // byte 18 b4
assert.strictEqual(TP_BITS[181], 'Proactive UICC: PROVIDE LOCAL INFORMATION (MEID)'); // byte 23 b3
assert.strictEqual(TP_BITS[260], 'Proactive UICC: PROVIDE LOCAL INFORMATION (Supported Radio Access Technologies)');
assert.strictEqual(TP_BITS[280], 'Data Connection Status Change Event support PDU Connection'); // byte 36 b8
assert.strictEqual(tpBitLabel(0), 'Profile download');
assert.match(tpBitLabel(400), /^RFU \(byte 51 b/); // beyond the table
});
test('3GPP-defined bits use the TS 31.111 names, not placeholders', () => {
assert.ok(!TP_BITS.some(l => /reserved by 3gpp/i.test(l)), 'no "reserved by 3GPP" labels');
assert.ok(!TP_BITS.some(l => /reserved by etsi/i.test(l)));
// a few audited 3GPP bits (TS 31.111 5.2)
const byteLabels = b => TP_BITS.slice((b - 1) * 8, b * 8);
assert.strictEqual(byteLabels(17)[6], 'E-UTRAN'); // byte 17 b2
assert.strictEqual(byteLabels(17)[7], 'HSDPA'); // byte 17 b1
assert.strictEqual(byteLabels(18)[5], 'CALL CONTROL on GPRS'); // byte 18 b3
assert.strictEqual(byteLabels(25)[4], 'Event: Network Rejection for GERAN/UTRAN');
assert.strictEqual(byteLabels(32)[0], 'IMS support'); // byte 32 b8
assert.strictEqual(byteLabels(34)[0], 'URI support for SEND SHORT MESSAGE');
assert.match(byteLabels(39)[0], /NG-RAN\/Satellite NG-RAN Timing Advance/);
});
test('TERMINAL PROFILE presets are valid even-length hex', () => {
assert.ok(TP_PRESETS.length >= 9);
// the project default stays first (it matches the CLI default profile)
assert.match(TP_PRESETS[0].name, /Xiaomi Mi A1/);
const names = TP_PRESETS.map(p => p.name);
for (const model of ['Quectel GSM module', 'Samsung S21+ 5G', 'Samsung A55 5G',
'Xiaomi Redmi Note 10 LTE', 'Sony Xperia Z5c LTE', 'Huawei E5573c / M150 (LTE)',
'Huawei E173 3G modem', 'Nokia 7210 2G']) {
assert.ok(names.some(n => n.includes(model)), model);
}
const seen = new Set();
for (const p of TP_PRESETS) {
assert.ok(p.name && p.profile, p.name);
assert.ok(/^[0-9A-F]+$/.test(p.profile) && p.profile.length % 2 === 0, p.name);
assert.ok(p.profile.length >= 8 && p.profile.length <= 510, p.name);
assert.ok(!seen.has(p.profile), 'duplicate profile: ' + p.name);
seen.add(p.profile);
}
});
test('Phone tab exposes the TERMINAL PROFILE block and Configure dialog', () => {
// the compact block has no room for the hex value: Send + Configure only
assert.ok(!html.includes('id="tp-current"'));
assert.match(html, /id="tp-send-btn"[^>]*data-needs="card"/);
assert.match(html, /id="tp-configure-btn"[^>]*data-needs="server"/);
assert.ok(html.includes('id="tp-modal"'));
assert.ok(html.includes('id="tp-preset"'));
assert.ok(html.includes('id="tp-hex"'));
assert.ok(html.includes('id="tp-form"'));
assert.ok(html.includes('id="tp-apply-btn"'));
// the preset select sits above the hex field, not next to it
assert.ok(html.indexOf('id="tp-preset"') < html.indexOf('id="tp-hex"'));
// Apply/Cancel sit at the top (right of the preset/hex fields), above the
// long bits grid, so they are reachable without scrolling
assert.ok(html.indexOf('id="tp-apply-btn"') < html.indexOf('id="tp-form"'));
});
test('tpLayoutGroups lays the byte blocks out in the configured columns', () => {
assert.deepStrictEqual(tpLayoutGroups(33), [
{ from: 1, to: 12, cols: 2 },
{ from: 13, to: 16, cols: 4 },
{ from: 17, to: 18, cols: 2 },
{ from: 19, to: 21, cols: 3 },
{ from: 22, to: 25, cols: 2 },
{ from: 26, to: 28, cols: 3 },
{ from: 29, to: 30, cols: 2 },
{ from: 31, to: 33, cols: 1 },
]);
// short profiles clamp: only existing bytes get a group
assert.deepStrictEqual(tpLayoutGroups(8), [{ from: 1, to: 8, cols: 2 }]);
assert.deepStrictEqual(tpLayoutGroups(18), [
{ from: 1, to: 12, cols: 2 },
{ from: 13, to: 16, cols: 4 },
{ from: 17, to: 18, cols: 2 },
]);
// long profiles: everything past byte 30 is one per row
assert.deepStrictEqual(tpLayoutGroups(40).slice(-1), [{ from: 31, to: 40, cols: 1 }]);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
version = "2.0.0"
version = "2.2.11"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+5 -2
View File
@@ -42,8 +42,10 @@ def main():
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
help='SM-SC address for SMS-SUBMIT routing in PoR-in-submit mode (default: 12345678912)')
parser.add_argument('--terminal-profile', default='7FFFFFFFFF0000CF02', metavar='HEX',
help='TERMINAL PROFILE payload (default: 10-byte profile with SMS-PP download and event list)')
parser.add_argument('--terminal-profile',
default='FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003',
metavar='HEX',
help='TERMINAL PROFILE payload (default: the 33-byte profile of a real BIP-capable handset - the live card only starts HTTP OTA when BIP events/commands are advertised)')
parser.add_argument('--poll-interval', type=int, default=30, metavar='SECS',
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
parser.add_argument('--no-card-init', action='store_true', default=False,
@@ -177,6 +179,7 @@ def main():
server.sms_sc = opts.sms_sm_sc
server.log_requests = opts.log_requests
server.terminal_profile = opts.terminal_profile
server.cli_terminal_profile = opts.terminal_profile
server.web_dir = opts.web_dir
server.sim_menu = sim_menu
server.event_list = event_list
+444
View File
@@ -0,0 +1,444 @@
"""HTTP OTA (SCP81 / GP RAM over HTTP) emulation.
Phase A: terminal-side BIP emulation (OPEN/SEND/RECEIVE/CLOSE CHANNEL) plus a
raw TCP capture listener. The card's BIP channel is always redirected to the
locally configured target (the future PSK TLS platform); the address the card
requested is only logged.
Reference behavior (TS 102 223 8.52-8.56, GP v2.2 Amendment B) is taken from
the captured real-terminal traces in samples/HTTP_OTA/traces:
OPEN CHANNEL TR: result, Channel status (38), Bearer description (35), Buffer size (39)
SEND DATA TR: result, Channel data length (37)
RECEIVE DATA TR: result, Channel data (36), Channel data length (37)
CLOSE CHANNEL TR: result
"""
import socket
import threading
import time
MAX_LOG = 1000
def ber_len_read(data, off):
"""Read a BER-TLV length at data[off]; returns (length, next_offset)."""
if off >= len(data):
return 0, off
b = data[off]
if b < 0x80:
return b, off + 1
n = b & 0x7F
if n == 0 or off + 1 + n > len(data):
return 0, len(data)
return int.from_bytes(data[off + 1:off + 1 + n], 'big'), off + 1 + n
def proactive_tlvs(raw):
"""Top-level TLV map {tag: value} of a D0 proactive command."""
out = {}
if not raw or raw[0] != 0xD0:
return out
ln, off = ber_len_read(raw, 1)
end = min(len(raw), off + ln)
while off + 1 < end:
tag = raw[off]
tlen, off2 = ber_len_read(raw, off + 1)
val = raw[off2:off2 + tlen]
off = off2 + tlen
out.setdefault(tag, val)
return out
def parse_other_address(value):
"""Decode an 'Other address' TLV (21=IPv4, 57=IPv6, F0=FQDN)."""
if not value:
return None
t = value[0]
if t == 0x21 and len(value) >= 5:
return '.'.join(str(b) for b in value[1:5])
if t == 0x57 and len(value) >= 17:
return ':'.join('%x' % int.from_bytes(value[i:i + 2], 'big') for i in range(1, 17, 2))
if t == 0xF0:
return value[1:].decode('ascii', 'replace')
return None
def parse_transport_level(value):
"""Decode an UICC/terminal interface transport level TLV -> (proto, port)."""
if not value or len(value) < 3:
return None, None
return value[0], int.from_bytes(value[1:3], 'big')
TAG_BEARER = 0x35
TAG_CHANNEL_DATA = 0x36
TAG_CHANNEL_DATA_LENGTH = 0x37
TAG_CHANNEL_STATUS = 0x38
TAG_BUFFER_SIZE = 0x39
TAG_TRANSPORT_LEVEL = 0x3C
TAG_OTHER_ADDRESS = 0x3E
TAG_NAA = 0x47
class BipChannel:
def __init__(self, channel_id, sock, requested, target, buffer_size):
self.id = channel_id
self.sock = sock
self.requested = requested
self.target = target
self.buffer_size = buffer_size or 512
self.rx = bytearray()
self.bytes_in = 0
self.bytes_out = 0
self.opened_at = time.time()
self.peer_closed = False
self.closed_reported = False
self.notified_len = 0
self.last_notify = 0.0
def pump(self, timeout=0.05):
"""Move whatever the network has into the local buffer. Returns bytes moved."""
if self.peer_closed:
return 0
moved = 0
self.sock.settimeout(timeout)
try:
while True:
chunk = self.sock.recv(self.buffer_size)
if not chunk:
self.peer_closed = True
break
self.rx.extend(chunk)
self.bytes_in += len(chunk)
moved += len(chunk)
if len(chunk) < self.buffer_size:
break
except (socket.timeout, BlockingIOError):
pass
except OSError:
self.peer_closed = True
return moved
def send(self, data):
self.sock.sendall(data)
self.bytes_out += len(data)
def take(self, maxlen):
self.pump()
n = min(maxlen, len(self.rx), self.buffer_size)
out = bytes(self.rx[:n])
del self.rx[:n]
return out
def available(self):
self.pump()
return len(self.rx)
def send_capacity(self):
free = self.buffer_size - len(self.rx)
return 0xFF if free > 0xFF else max(0, free)
def close(self):
try:
self.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.sock.close()
except OSError:
pass
class BipTerminal:
"""Terminal (device) side of BIP: channels to the configured target."""
def __init__(self):
self.enabled = False
self.target = None
self.channels = {}
self.next_id = 1
self.entries = []
self.seq = 0
self.lock = threading.Lock()
self.pending_events = []
self.on_data = None
self._monitor = None
def log(self, kind, **fields):
with self.lock:
self.seq += 1
entry = {'seq': self.seq, 't': time.time(), 'kind': kind}
entry.update(fields)
self.entries.append(entry)
if len(self.entries) > MAX_LOG:
del self.entries[:len(self.entries) - MAX_LOG]
return entry
def _monitor_loop(self):
"""Watch channels for incoming bytes and ask the card to fetch them.
The card only learns about server data through the Data available
event (TS 102 223 7.5.10), so the socket must be pumped even while
the card is idle."""
while True:
time.sleep(0.25)
with self.lock:
channels = list(self.channels.values())
for ch in channels:
try:
ch.pump()
except OSError:
ch.peer_closed = True
if ch.peer_closed and not ch.closed_reported and not ch.rx:
# Report a dropped link (TS 102 223 7.5.11) only once the
# buffered server data has been fetched: signalling the
# drop while bytes are still waiting makes the card abort
# the fetch and end the session prematurely.
ch.closed_reported = True
self.log('peer-close', channel=ch.id)
self._queue_link_status(ch.id)
if (self.on_data and ch.rx and not ch.peer_closed
and (len(ch.rx) > ch.notified_len
or time.time() - ch.last_notify > 2.0)):
# Re-notify while data stays unfetched: the live card
# sometimes needs the Data available event again to drain
# a partially received TLS record.
if self.on_data(ch):
ch.notified_len = len(ch.rx)
ch.last_notify = time.time()
def _start_monitor(self):
if self._monitor is None or not self._monitor.is_alive():
self._monitor = threading.Thread(target=self._monitor_loop,
name='bip-monitor', daemon=True)
self._monitor.start()
def enable(self, host, port):
self.target = (host, int(port))
self.enabled = True
self.log('enabled', target='%s:%d' % self.target)
self._start_monitor()
def disable(self):
self.enabled = False
self.log('disabled')
self.close_all(link_lost=True)
self.target = None
def close_all(self, link_lost=False):
for ch in list(self.channels.values()):
self._close_channel(ch, link_lost=link_lost)
def _close_channel(self, ch, link_lost=False):
ch.close()
if self.channels.get(ch.id) is ch:
del self.channels[ch.id]
if link_lost:
self._queue_link_status(ch.id)
def _queue_link_status(self, channel_id, status=None, info=0x05):
"""Record a BIP link change that did not result from a proactive
command (TS 102 223 7.5.11). The default is link not established +
info 05 = link dropped; a successful background-mode OPEN CHANNEL
reports link established instead. The server turns these into
ENVELOPE (Channel status)."""
with self.lock:
if any(e['channel'] == channel_id for e in self.pending_events):
return
self.pending_events.append({
'channel': channel_id,
'status': channel_id & 0x07 if status is None else status,
'info': info})
def take_pending_events(self):
with self.lock:
events, self.pending_events = self.pending_events, []
return events
def _check_peer(self, ch):
"""Notify once per channel when the peer closed the connection, after
any buffered data has been fetched (see _monitor_loop)."""
if ch.peer_closed and not ch.closed_reported and not ch.rx:
ch.closed_reported = True
self.log('peer-close', channel=ch.id)
self._queue_link_status(ch.id)
def _alloc_id(self):
for _ in range(7):
cid = self.next_id
self.next_id = 1 if cid >= 7 else cid + 1
if cid not in self.channels:
return cid
return None
def open(self, requested_host, requested_port, buffer_size):
"""Open a channel to the redirect target. Returns (channel_id, error)."""
if not self.enabled or not self.target:
return None, 'bip disabled'
target = self.target
requested = '%s:%s' % (requested_host, requested_port)
cid = self._alloc_id()
if cid is None:
self.log('open-fail', requested=requested, reason='no free channel')
return None, 'no free channel'
try:
sock = socket.create_connection(target, timeout=2.0)
except OSError as e:
self.log('open-fail', requested=requested, target='%s:%d' % target, reason=str(e))
return None, str(e)
ch = BipChannel(cid, sock, requested, target, buffer_size)
self.channels[cid] = ch
self.log('open', channel=cid, requested=requested, target='%s:%d' % target,
buffer_size=ch.buffer_size)
return cid, None
def send(self, channel_id, data):
ch = self.channels.get(channel_id)
if not ch:
return False
try:
ch.send(data)
except OSError as e:
self.log('send-fail', channel=channel_id, error=str(e))
self._close_channel(ch, link_lost=True)
return False
self.log('send', channel=channel_id, bytes=len(data), hex=data.hex().upper()[:2000])
return True
def receive(self, channel_id, maxlen):
ch = self.channels.get(channel_id)
if not ch:
return None
data = ch.take(maxlen)
if data:
self.log('receive', channel=channel_id, bytes=len(data), remaining=len(ch.rx),
hex=data.hex().upper()[:2000])
# The TR announced the remainder via the channel-data-length TLV,
# but the live card still waits for a fresh Data available event
# before fetching it - re-arm the notification for what is left.
ch.notified_len = 0
self._check_peer(ch)
return data
def available(self, channel_id):
ch = self.channels.get(channel_id)
if not ch:
return 0
n = ch.available()
self._check_peer(ch)
return n
def send_capacity(self, channel_id):
ch = self.channels.get(channel_id)
if not ch:
return 0
n = ch.send_capacity()
self._check_peer(ch)
return n
def clear_log(self):
with self.lock:
self.entries = []
def close(self, channel_id):
ch = self.channels.get(channel_id)
if not ch:
return False
self.log('close', channel=channel_id, bytes_in=ch.bytes_in, bytes_out=ch.bytes_out)
self._close_channel(ch)
return True
def status(self):
channels = []
for ch in self.channels.values():
channels.append({
'id': ch.id,
'requested': ch.requested,
'target': '%s:%d' % ch.target,
'buffer_size': ch.buffer_size,
'bytes_in': ch.bytes_in,
'bytes_out': ch.bytes_out,
'pending': len(ch.rx),
'peer_closed': ch.peer_closed,
})
return {
'enabled': self.enabled,
'target': '%s:%d' % self.target if self.target else None,
'channels': channels,
'seq': self.seq,
}
def entries_after(self, after=0):
with self.lock:
return [e for e in self.entries if e['seq'] > after]
class TcpDumpServer:
"""Plain TCP listener that logs whatever it receives (ClientHello capture).
Used as the BIP redirect target until the PSK TLS platform is brought up.
"""
def __init__(self, host, port, on_rx=None, on_log=None):
self.on_rx = on_rx
self.on_log = on_log
self.stopped = False
self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind((host, int(port)))
self.sock.listen(4)
self.host, self.port = self.sock.getsockname()[:2]
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
self.thread.start()
if self.on_log:
self.on_log('listener-start', host=self.host, port=self.port)
def _accept_loop(self):
while not self.stopped:
try:
self.sock.settimeout(0.2)
conn, addr = self.sock.accept()
except socket.timeout:
continue
except OSError:
break
self.conns.append(conn)
if self.on_log:
self.on_log('conn', peer='%s:%d' % addr[:2])
threading.Thread(target=self._conn_loop, args=(conn, addr), daemon=True).start()
def _conn_loop(self, conn, addr):
try:
while not self.stopped:
conn.settimeout(0.2)
try:
data = conn.recv(4096)
except socket.timeout:
continue
except OSError:
break
if not data:
break
if self.on_rx:
self.on_rx('%s:%d' % addr[:2], data)
finally:
try:
conn.close()
except OSError:
pass
def stop(self):
self.stopped = True
if self.on_log:
self.on_log('listener-stop', host=self.host, port=self.port)
try:
self.sock.close()
except OSError:
pass
for conn in self.conns:
try:
conn.close()
except OSError:
pass
self.conns = []
+450
View File
@@ -0,0 +1,450 @@
"""Phase B: PSK TLS server and HTTP administration session for SCP81.
Implements the Remote Administration Server side of GP RAM over HTTP
(GPC v2.2 Amendment B):
- TLS 1.2 with the PSK cipher suites of clause 4.3.2. The handshake and
record layer are handled by the stdlib ``ssl`` module through OpenSSL's
PSK callbacks (identity -> PSK), so no TLS code lives here.
- The HTTP dialog of clause 4.4: parse the Security Domain's POST
(``X-Admin-*`` headers, optional body with the previous response string)
and answer with 200 + a command string, or 204 No Content to close the
administration session.
The card talks TLS *through* the BIP channel: this server listens on the
local redirect target and the BIP terminal proxies the card's SEND/RECEIVE
DATA records to it.
"""
import socket
import ssl
import threading
import time
MAX_HEAD = 32 * 1024
MAX_BODY = 1 * 1024 * 1024
# TLS_PSK_* suites from GPC v2.2 Amendment B Table 4-2 / RFC 4279/4785/5487.
PSK_CIPHERS = ':'.join([
'PSK-AES128-CBC-SHA256', # TLS_PSK_WITH_AES_128_CBC_SHA256 (0x00AE)
'PSK-AES128-CBC-SHA', # TLS_PSK_WITH_AES_128_CBC_SHA (0x008C)
'PSK-AES256-CBC-SHA', # TLS_PSK_WITH_AES_256_CBC_SHA (0x008D)
'PSK-3DES-EDE-CBC-SHA', # TLS_PSK_WITH_3DES_EDE_CBC_SHA (0x008B)
'PSK-NULL-SHA256', # TLS_PSK_WITH_NULL_SHA256 (0x00B0)
'PSK-NULL-SHA', # TLS_PSK_WITH_NULL_SHA (0x002C)
])
GP_PROTOCOL = 'globalplatform-remote-admin/1.0'
GP_CT_COMMAND = 'application/vnd.globalplatform.card-content-mgt;version=1.0'
GP_CT_RESPONSE = 'application/vnd.globalplatform.card-content-mgt-response;version=1.0'
# OpenSSL SSL_OP_NO_ENCRYPT_THEN_MAC (not exposed by the ssl module). The live
# card offers the encrypt_then_mac extension but aborts the session with
# SSLV3_ALERT_UNEXPECTED_MESSAGE as soon as the server echoes it, so keep the
# extension out of the ServerHello (verified live 2026-09-15).
OP_NO_ENCRYPT_THEN_MAC = 0x00080000
TLS_VERSIONS = {
'1.0': ssl.TLSVersion.TLSv1,
'1.1': ssl.TLSVersion.TLSv1_1,
'1.2': ssl.TLSVersion.TLSv1_2,
}
def _norm_identity(identity):
"""Normalize a PSK identity to the str OpenSSL reports (CPython hands it
to the PSK callback as a str; bytes are decoded byte-exact)."""
if identity is None:
return None
if isinstance(identity, (bytes, bytearray)):
return bytes(identity).decode('latin-1')
return str(identity)
def parse_http_request(data):
"""Parse an HTTP/1.1 request head (bytes up to CRLFCRLF) into
(method, target, headers dict with lower-case names)."""
head = data.split(b'\r\n\r\n', 1)[0]
lines = head.split(b'\r\n')
parts = lines[0].split(b' ')
if len(parts) < 3:
raise ValueError('malformed request line')
method, target = parts[0].decode('latin-1'), parts[1].decode('latin-1')
headers = {}
for line in lines[1:]:
name, _, value = line.partition(b':')
headers[name.strip().decode('latin-1').lower()] = value.strip().decode('latin-1')
return method, target, headers
def decode_chunked(body):
"""Decode a chunked transfer body (RFC 2616 3.6.1)."""
out = bytearray()
while body:
line, _, rest = body.partition(b'\r\n')
try:
size = int(line.split(b';')[0], 16)
except ValueError:
raise ValueError('bad chunk size %r' % line[:16])
if size == 0:
break
out.extend(rest[:size])
body = rest[size + 2:]
return bytes(out)
def build_http_response(status, reason, headers, body=b'', chunked=False,
compact=False, connection=None):
"""Build an HTTP response. With chunked=True the body is framed as 100-byte
chunks (like the reference admin server); with compact=True header names
and values are separated by ':' without whitespace, which keeps the whole
response inside one card-sized TLS record (<= 256 bytes ciphertext).
connection ('close'/'keep-alive') declares the connection fate: without
it an HTTP/1.1 client assumes the connection persists and tries to reuse
it for the next POST instead of dialing a new one (live card 2026-09-15)."""
lines = ['HTTP/1.1 %d %s' % (status, reason)]
sep = ':' if compact else ': '
for name, value in headers.items():
lines.append('%s%s%s' % (name, sep, value))
if connection:
lines.append('Connection%s%s' % (sep, connection))
has_te = 'transfer-encoding' in [k.lower() for k in headers]
if body and (chunked or has_te):
if not has_te:
lines.append('Transfer-Encoding: chunked')
elif body and 'content-length' not in [k.lower() for k in headers]:
lines.append('Content-Length%s%d' % (sep, len(body)))
head = ('\r\n'.join(lines) + '\r\n\r\n').encode('latin-1')
if not body:
return head
if not chunked:
return head + body
out = bytearray(head)
for i in range(0, len(body), 100):
piece = body[i:i + 100]
out += ('%X\r\n' % len(piece)).encode('latin-1') + piece + b'\r\n'
out += b'0\r\n\r\n'
return bytes(out)
class PskTlsServer:
"""PSK TLS listener speaking the GP remote administration HTTP dialog."""
def __init__(self, host, port, psk=None, identity=None, on_log=None,
responder=None, timeout=10.0, chunked=False, chunk_size=0,
keep_alive=False, compact_headers=False, tls_version='1.2',
cipher=None, on_before_close=None, keylog=None,
conn_header=None, half_close=False, answer_delay=0.0,
psk_map=None):
# PSK lookup table: identity -> key. With an explicit psk_map a
# handshake is accepted only for a listed identity; the legacy
# single-key form (psk + optional identity pin, pin None = accept any
# identity) remains for scripts and tests.
self.wildcard_psk = None
self.psk_map = {}
if psk_map is not None:
self.psk_map = {_norm_identity(k): bytes(v)
for k, v in dict(psk_map).items() if v}
elif psk is not None:
pin = _norm_identity(identity)
if pin is None:
self.wildcard_psk = psk
else:
self.psk_map = {pin: bytes(psk)}
self.psk = psk
self.identity = _norm_identity(identity)
self.on_log = on_log
self.responder = responder or self._default_responder
self.timeout = timeout
self.chunked = chunked
# chunk_size 0 = one record for the whole response
self.chunk_size = int(chunk_size)
self.keep_alive = keep_alive
self.compact_headers = compact_headers
# The reference traces negotiated TLS 1.0 with PSK-AES128-CBC-SHA;
# some cards only speak the older record layer correctly.
self.tls_version = tls_version if tls_version in TLS_VERSIONS else '1.2'
# Pin one cipher suite (e.g. PSK-AES128-CBC-SHA) if the card's SD only
# maps a specific suite to a usable SCP81 security level.
self.cipher = cipher or None
# Called with the peer address just before closing a non-keep-alive
# connection: the server waits until the card has drained the BIP
# buffer, otherwise the EOF truncates the response fetch.
self.on_before_close = on_before_close
# Debug aid: write the TLS traffic secrets to this file
# (SSLKEYLOGFILE format), so captures of the PSK dialog can be
# decrypted (tshark etc). Contains key material - use a temp path.
self.keylog = keylog or None
# Connection header value: None = auto ('keep-alive'/'close' per the
# keep_alive flag), 'none' = omit the header (Apache-style implicit
# HTTP/1.1 keep-alive, as in the working reference trace).
self.conn_header = conn_header or None
# TLS half-close after a script body. NOTE (live 2026-09-16):
# CPython's SSLSocket.unwrap() poisons the session when the peer does
# not answer with its own close_notify in time, so this cannot be
# implemented with the stdlib ssl module; the flag is kept for the
# option surface and for cards that answer promptly (the exception
# path leaves the session unusable, so it is off by default).
self.half_close = half_close
# Wait before answering a request (the reference Apache/PHP servers
# answer ~1 s after the card's POST; the card may need its BIP
# SEND-DATA conversation to settle before it accepts the response).
self.answer_delay = float(answer_delay or 0)
self.identity_seen = None
self.identity_matched = None
self.stopped = False
self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# A quick Stop -> Start can race the previous listener's close (the
# port stays busy for a moment); retry before giving up.
last_error = None
for _ in range(10):
try:
self.sock.bind((host, int(port)))
last_error = None
break
except OSError as e:
last_error = e
time.sleep(0.3)
if last_error is not None:
self.sock.close()
raise last_error
self.sock.listen(4)
self.host, self.port = self.sock.getsockname()[:2]
self.ctx = self._make_context()
if self.keylog:
try:
self.ctx.keylog_filename = self.keylog
except (AttributeError, OSError):
self.keylog = None
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
self.thread.start()
self.log('tls-listener-start', host=self.host, port=self.port)
def log(self, kind, **fields):
if self.on_log:
try:
self.on_log(kind, **fields)
except Exception:
pass
def _make_context(self):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ver = TLS_VERSIONS[self.tls_version]
ctx.minimum_version = ver
ctx.maximum_version = ver
ciphers = self.cipher or PSK_CIPHERS
if self.tls_version in ('1.0', '1.1'):
# OpenSSL 3.x disables the legacy protocol versions by default.
ciphers += ':@SECLEVEL=0'
ctx.set_ciphers(ciphers)
# Prefer our (AES-first) order over the card's NULL-suite-first list.
ctx.options |= ssl.OP_CIPHER_SERVER_PREFERENCE
ctx.options |= OP_NO_ENCRYPT_THEN_MAC
# No TLS session resumption: the live card aborts with
# SSLV3_ALERT_UNEXPECTED_MESSAGE on the post-handshake
# NewSessionTicket record (verified live 2026-09-15).
ctx.options |= ssl.OP_NO_TICKET
ctx.set_psk_server_callback(self._psk_cb)
return ctx
def _psk_cb(self, identity):
"""OpenSSL asks for the key of the identity the client sent.
The identity is looked up in the configured table (identity -> key);
without a match the handshake fails on the Finished MAC check with a
dummy key, and the attempt is logged as 'tls-psk-unknown'."""
ident = _norm_identity(identity)
self.identity_seen = ident
key = self.psk_map.get(ident) if ident is not None else None
if key is None:
# Legacy single-key mode: no identity pin accepts any identity.
key = self.wildcard_psk
self.identity_matched = key is not None
if key is None:
self.log('tls-psk-unknown', identity=ident)
return b'\x00' * 16
return key
@property
def psk_identities(self):
"""Identities the listener looks up (keys are never exposed)."""
return sorted(self.psk_map)
def set_psk_map(self, psk_map):
"""Replace the identity -> key table of a running listener."""
self.psk_map = {_norm_identity(k): bytes(v)
for k, v in dict(psk_map).items() if v}
self.wildcard_psk = None
return self.psk_identities
@staticmethod
def _default_responder(method, target, headers, body):
"""No script configured: close the administration session (4.4.2)."""
return 204, {'X-Admin-Protocol': GP_PROTOCOL}, b''
def _accept_loop(self):
while not self.stopped:
try:
self.sock.settimeout(0.2)
conn, addr = self.sock.accept()
except socket.timeout:
continue
except OSError:
break
self.conns.append(conn)
peer = '%s:%d' % addr[:2]
threading.Thread(target=self._conn_loop, args=(conn, peer),
daemon=True).start()
def _read_request(self, tls):
buf = b''
while b'\r\n\r\n' not in buf:
chunk = tls.recv(4096)
if not chunk:
return None
buf += chunk
if len(buf) > MAX_HEAD:
raise ValueError('request head too large')
head, _, rest = buf.partition(b'\r\n\r\n')
method, target, headers = parse_http_request(head + b'\r\n\r\n')
body = rest
if 'content-length' in headers:
want = int(headers['content-length'])
while len(body) < want:
chunk = tls.recv(4096)
if not chunk:
break
body += chunk
body = body[:want]
elif headers.get('transfer-encoding', '').lower() == 'chunked':
while not body.endswith(b'0\r\n\r\n'):
chunk = tls.recv(4096)
if not chunk:
break
body += chunk
body = decode_chunked(body)
return method, target, headers, body
def _conn_loop(self, conn, peer):
tls = None
try:
tls = self.ctx.wrap_socket(conn, server_side=True)
self.log('tls-handshake', peer=peer, cipher=tls.cipher()[0],
version=tls.version(), identity=self.identity_seen,
psk_match=self.identity_matched)
while not self.stopped:
req = self._read_request(tls)
if req is None:
break
method, target, headers, body = req
if self.answer_delay > 0:
time.sleep(self.answer_delay)
self.log('tls-request', peer=peer, method=method, uri=target,
headers=headers,
agent=headers.get('x-admin-from'),
protocol=headers.get('x-admin-protocol'),
script_status=headers.get('x-admin-script-status'),
resume=headers.get('x-admin-resume'),
content_type=headers.get('content-type'),
bytes=len(body), body_hex=body.hex().upper()[:2000] or None)
status, resp_headers, resp_body = self.responder(
method, target, headers, body)
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
conn_hdr = self.conn_header
if conn_hdr == 'none':
conn_hdr = None
elif conn_hdr is None:
conn_hdr = 'keep-alive' if self.keep_alive else 'close'
response = build_http_response(
status, reason, resp_headers, resp_body,
chunked=self.chunked, compact=self.compact_headers,
connection=conn_hdr)
# The card's HTTP client reads its response record-by-record:
# the whole response must arrive in ONE TLS record (chunk_size
# 0), otherwise a split head stalls it and a head-only record
# followed by the body draws an unexpected_message alert. When
# a chunk_size is given, the head goes in one record and the
# body in pieces of that size.
if self.chunk_size <= 0:
tls.sendall(response)
else:
head, sep, rest = response.partition(b'\r\n\r\n')
tls.sendall(head + sep if sep else head)
for off in range(0, len(rest), self.chunk_size):
tls.sendall(rest[off:off + self.chunk_size])
self.log('tls-response', peer=peer, status=status,
bytes=len(resp_body), chunked=self.chunked,
response_hex=response.hex().upper()[:600],
body_hex=resp_body.hex().upper()[:2000] or None)
# 204 always ends the dialog. Without keep-alive every response
# ends it: the card's HTTP client appears to delimit the
# response at connection close (live 2026-09-15) and then
# starts a fresh session for its next POST.
if status == 204 or not resp_body or not self.keep_alive:
peer_name = None
if resp_body and self.on_before_close:
try:
peer_name = tls.getpeername()
except Exception:
peer_name = None
plain = None
if not self.keep_alive:
# Clean TLS shutdown BEFORE the card drains the
# buffer: a bare TCP close leaves the card's TLS stack
# with a truncated session (it then neither processes
# the script nor posts the response), and a
# close_notify sent only after the drain is never
# fetched. Send it while the response still waits, so
# the card reads both, then wait for the buffer to
# drain and only then send the FIN.
try:
tls.settimeout(2.0)
plain = tls.unwrap()
tls = None
except Exception:
plain = None
if peer_name and self.on_before_close:
try:
self.on_before_close(peer_name)
except Exception:
pass
if plain is not None:
try:
plain.close()
except OSError:
pass
break
except ssl.SSLError as e:
self.log('tls-error', peer=peer, error=str(e))
except (OSError, ValueError) as e:
self.log('tls-error', peer=peer, error=str(e))
finally:
if tls is not None:
try:
tls.close()
except OSError:
pass
else:
try:
conn.close()
except OSError:
pass
self.log('tls-close', peer=peer)
if conn in self.conns:
self.conns.remove(conn)
def stop(self):
self.stopped = True
self.log('tls-listener-stop', host=self.host, port=self.port)
try:
self.sock.close()
except OSError:
pass
for conn in list(self.conns):
try:
conn.close()
except OSError:
pass
self.conns = []
File diff suppressed because it is too large Load Diff
+454
View File
@@ -0,0 +1,454 @@
#!/usr/bin/env python3
"""Unit tests for the HTTP OTA (SCP81) BIP terminal emulation (Phase A).
TR byte vectors come from the captured real-terminal traces in
samples/HTTP_OTA/traces (OPEN CHANNEL success/failure, SEND/RECEIVE/CLOSE).
No live card or live card data is used here.
"""
import socket
import sys
import threading
import time
import types
import unittest
from pathlib import Path
from unittest import mock
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_otaman_server import httpota
import pysim_otaman_server.server as server
OPEN_LOCALHOST = bytes.fromhex(
'd02b010301400102028182050035010339020200470b076d656761666f6e2e7275'
'3c03021f903e05217f000001')
TR_OPEN_OK = '0103014001020282810301003802810035010339020200'
TR_OPEN_FAIL = '01030140010202828103023a0035010339020200'
class PeerServer(threading.Thread):
"""Tiny TCP peer: accepts one connection, greets, records what it receives."""
def __init__(self, greeting=b''):
super().__init__(daemon=True)
self.sock = socket.socket()
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(('127.0.0.1', 0))
self.sock.listen(1)
self.port = self.sock.getsockname()[1]
self.greeting = greeting
self.received = b''
self.conn = None
self.ready = threading.Event()
self.done = threading.Event()
def run(self):
self.sock.settimeout(3)
try:
self.conn, _ = self.sock.accept()
except OSError:
return
self.ready.set()
if self.greeting:
self.conn.sendall(self.greeting)
self.conn.settimeout(2)
deadline = time.time() + 3
try:
while time.time() < deadline:
try:
data = self.conn.recv(4096)
except socket.timeout:
break
if not data:
break
self.received += data
except OSError:
pass
self.done.set()
def stop(self):
self.sock.close()
def open_cmd(host, port, buffer_size=512):
ip = bytes(int(x) for x in host.split('.'))
tlvs = (b'\x81\x03\x01\x40\x01'
b'\x82\x02\x81\x82'
b'\x35\x01\x03'
b'\x39\x02' + buffer_size.to_bytes(2, 'big') +
b'\x3c\x03\x02' + port.to_bytes(2, 'big') +
b'\x3e\x05\x21' + ip)
return b'\xd0' + bytes([len(tlvs)]) + tlvs
def channel_cmd(cmd_type, qualifier, data_tlvs=b''):
tlvs = (bytes([0x81, 0x03, 0x01, cmd_type, qualifier]) +
b'\x82\x02\x81\x21' + data_tlvs)
return b'\xd0' + bytes([len(tlvs)]) + tlvs
class TlvTest(unittest.TestCase):
def test_proactive_tlvs_open_channel(self):
tlvs = httpota.proactive_tlvs(OPEN_LOCALHOST)
self.assertEqual(tlvs[httpota.TAG_BEARER], b'\x03')
self.assertEqual(tlvs[httpota.TAG_BUFFER_SIZE], b'\x02\x00')
self.assertEqual(tlvs[httpota.TAG_NAA], b'\x07megafon.ru')
self.assertEqual(httpota.parse_transport_level(tlvs[httpota.TAG_TRANSPORT_LEVEL]), (0x02, 8080))
self.assertEqual(httpota.parse_other_address(tlvs[httpota.TAG_OTHER_ADDRESS]), '127.0.0.1')
class TrVectorTest(unittest.TestCase):
def test_open_channel_success_vector(self):
extra = bytes([0x38, 0x02, 0x81, 0x00]) + bytes([0x35, 0x01, 0x03]) + bytes([0x39, 0x02, 0x02, 0x00])
tr = server._bip_tr(1, 0x40, 0x01, 0x81, 0x82, 0x00, None, extra)
self.assertEqual(tr.hex(), TR_OPEN_OK)
def test_open_channel_failure_vector(self):
extra = bytes([0x35, 0x01, 0x03]) + bytes([0x39, 0x02, 0x02, 0x00])
tr = server._bip_tr(1, 0x40, 0x01, 0x81, 0x82, 0x3A, 0x00, extra)
self.assertEqual(tr.hex(), TR_OPEN_FAIL)
def test_disabled_bip_fails_open_channel(self):
old = server._BIP
try:
server._BIP = httpota.BipTerminal()
tr = server._handle_bip_command(None, 1, 0x40, 0x01, OPEN_LOCALHOST, 0x81, 0x82)
self.assertEqual(tr.hex(), TR_OPEN_FAIL)
finally:
server._BIP = old
class BipTerminalTest(unittest.TestCase):
def test_redirect_and_roundtrip(self):
peer = PeerServer(greeting=b'SERVERHELLO')
peer.start()
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', peer.port)
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
self.assertEqual(bip.channels[cid].requested, '10.9.9.9:1234')
self.assertEqual(bip.channels[cid].target, ('127.0.0.1', peer.port))
self.assertTrue(bip.send(cid, b'CLIENTHELLO'))
data = b''
for _ in range(20):
data = bip.receive(cid, 100)
if data:
break
time.sleep(0.05)
self.assertEqual(data, b'SERVERHELLO')
self.assertTrue(bip.close(cid))
peer.done.wait(3)
self.assertEqual(peer.received, b'CLIENTHELLO')
kinds = [e['kind'] for e in bip.entries_after(0)]
self.assertIn('open', kinds)
self.assertIn('send', kinds)
self.assertIn('receive', kinds)
self.assertIn('close', kinds)
peer.stop()
def test_passthru_mode_roundtrip_via_bip_control(self):
# SCP81 passthru: the control API enables BIP with the external
# platform as the target and starts no local listener; the card's
# channel talks straight to that platform.
peer = PeerServer(greeting=b'PLATFORM')
peer.start()
try:
resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru',
'host': '127.0.0.1', 'port': peer.port})
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['listener']['mode'], 'passthru')
self.assertEqual(server._BIP.target, ('127.0.0.1', peer.port))
cid, err = server._BIP.open('10.9.9.9', 10174, 512)
self.assertIsNone(err)
self.assertTrue(server._BIP.send(cid, b'CARDHELLO'))
data = b''
for _ in range(20):
data = server._BIP.receive(cid, 100)
if data:
break
time.sleep(0.05)
self.assertEqual(data, b'PLATFORM')
finally:
server._scp81_bip_control({'action': 'stop'})
peer.stop()
def test_disabled_terminal_refuses_open(self):
bip = httpota.BipTerminal()
cid, err = bip.open('127.0.0.1', 1, 512)
self.assertIsNone(cid)
self.assertIn('disabled', err)
def test_peer_close_queues_channel_status_event(self):
# TS 102 223 7.5.11: a link lost outside a proactive command must be
# reported to the UICC (channel id, link not established, info 05).
srv = socket.socket()
srv.bind(('127.0.0.1', 0))
srv.listen(1)
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', srv.getsockname()[1])
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
conn, _ = srv.accept()
conn.close()
events = []
for _ in range(40):
bip.receive(cid, 16)
events = bip.take_pending_events()
if events:
break
time.sleep(0.05)
self.assertEqual(events, [{'channel': cid, 'status': cid, 'info': 0x05}])
finally:
srv.close()
def test_channel_status_queued_once_per_channel(self):
bip = httpota.BipTerminal()
bip._queue_link_status(3)
bip._queue_link_status(3)
self.assertEqual(bip.take_pending_events(),
[{'channel': 3, 'status': 3, 'info': 0x05}])
self.assertEqual(bip.take_pending_events(), [])
def test_proactive_close_does_not_queue_status(self):
# A CLOSE CHANNEL proactive command is not an autonomous link change.
peer = PeerServer()
peer.start()
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', peer.port)
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
self.assertTrue(bip.close(cid))
self.assertEqual(bip.take_pending_events(), [])
finally:
peer.stop()
def test_dump_server_logs_received_bytes(self):
received = []
dump = httpota.TcpDumpServer('127.0.0.1', 0, on_rx=lambda peer, data: received.append(data))
c = socket.create_connection(('127.0.0.1', dump.port), timeout=2)
c.sendall(b'HELLOCARD')
deadline = time.time() + 2
while time.time() < deadline and not received:
time.sleep(0.02)
c.close()
dump.stop()
self.assertEqual(b''.join(received), b'HELLOCARD')
def parse_tr(tr):
"""Parse a BIP TERMINAL RESPONSE payload into {tag: value}."""
out = {}
off = 0
while off + 1 < len(tr):
tag, ln = tr[off], tr[off + 1]
out[tag] = tr[off + 2:off + 2 + ln]
off += 2 + ln
return out
class BipCommandFlowTest(unittest.TestCase):
def setUp(self):
self.peer = PeerServer(greeting=b'SERVERHELLO')
self.peer.start()
self.old = server._BIP
self.bip = httpota.BipTerminal()
self.bip.enable('127.0.0.1', self.peer.port)
server._BIP = self.bip
def tearDown(self):
server._BIP = self.old
self.peer.stop()
def test_open_send_receive_close_flow(self):
tr = server._handle_bip_command(None, 1, 0x40, 0x01, open_cmd('127.0.0.1', self.peer.port), 0x81, 0x82)
self.assertEqual(tr.hex(), TR_OPEN_OK)
tr = server._handle_bip_command(None, 1, 0x43, 0x01,
channel_cmd(0x43, 0x01, bytes([0x36, 0x08]) + b'CLIENTHE'),
0x81, 0x21)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x01].hex(), '014301')
self.assertEqual(tlvs[0x02].hex(), '8281')
self.assertEqual(tlvs[0x03], b'\x00')
self.assertEqual(tlvs[0x37], b'\xff')
data = b''
for _ in range(20):
tr = server._handle_bip_command(None, 1, 0x42, 0x00, channel_cmd(0x42, 0x00, bytes([0x37, 0x01, 0x64])), 0x81, 0x21)
tlvs = parse_tr(tr)
if 0x36 in tlvs and tlvs[0x36]:
data += tlvs[0x36]
break
time.sleep(0.05)
self.assertEqual(data, b'SERVERHELLO')
self.assertEqual(tlvs[0x37], b'\x00')
tr = server._handle_bip_command(None, 1, 0x41, 0x00, channel_cmd(0x41, 0x00), 0x81, 0x21)
self.assertEqual(tr.hex(), '010301410002028281030100')
self.peer.done.wait(3)
self.assertEqual(self.peer.received, b'CLIENTHE')
def test_send_without_channel_fails(self):
tr = server._handle_bip_command(None, 1, 0x43, 0x01,
channel_cmd(0x43, 0x01, bytes([0x36, 0x01]) + b'X'),
0x81, 0x21)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03].hex(), '3a00')
def test_open_channel_cr_set_tlvs(self):
# Live card 2026-09-15: the fallback OPEN CHANNEL uses the CR-set tag
# variants (B5/B9/C7/BC/BE) - the handler must find them too.
raw = bytes.fromhex('d0248103014003820281828500b50103b902058e'
'c70403475042bc03020582be05215bd50502')
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs) # Channel status
self.assertIn(0x39, tlvs) # Buffer size echo
def test_open_channel_plain_tlvs(self):
# Same command with the plain tag variants (reference phone traces).
raw = bytes.fromhex('d02401030140030202818205003501033902058e'
'4704034750423c030205823e05215bd50502')
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
def test_open_channel_truncated_destination_accepted(self):
# Live card 2026-09-15: '3e 05' with no value (empty buffer quirk,
# same family as the reference openchannel_not_understood_no_apn
# trace). The emulation is permissive and opens the configured target.
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
'4701003c030227be3e05')
tr = server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
kinds = [(e['kind'], e.get('note')) for e in self.bip.entries_after(0)]
self.assertIn(('open-relaxed', 'destination/transport not fully specified'), kinds)
def test_open_channel_without_transport_accepted(self):
# No transport level at all (bearer-level channel): still accepted.
raw = bytes.fromhex('d00d81030140018202818239020200')
tr = server._handle_bip_command(None, 1, 0x40, 0x01, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
kinds = [e['kind'] for e in self.bip.entries_after(0)]
self.assertIn('open-relaxed', kinds)
def test_background_open_queues_link_established(self):
# Qualifier 0x04 (background mode): the terminal must report the
# established link via ENVELOPE (Channel status) - 7.5.11.
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
'4701003c030227be3e05')
server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
events = self.bip.take_pending_events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0]['info'], 0x00)
self.assertTrue(events[0]['status'] & 0x80)
def test_flush_channel_events_when_subscribed(self):
sent = []
class Tp:
def send_apdu(self, apdu):
sent.append(apdu)
return '', '9000'
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
self.bip._queue_link_status(2)
ref = types.SimpleNamespace(event_list=[0x09, 0x0A])
with mock.patch.object(server, '_server_ref', ref):
server._bip_flush_channel_events(scc)
# D6: event list (ch status), device ids, Channel status B8 02 02 05
# (channel 2, link not established, info 05 = link dropped)
self.assertEqual(sent, ['80c200000dd60b99010a82028281b8020205'])
self.assertEqual(self.bip.take_pending_events(), [])
def test_flush_skipped_without_subscription(self):
sent = []
class Tp:
def send_apdu(self, apdu):
sent.append(apdu)
return '', '9000'
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
self.bip._queue_link_status(1)
ref = types.SimpleNamespace(event_list=[0x09])
with mock.patch.object(server, '_server_ref', ref):
server._bip_flush_channel_events(scc)
self.assertEqual(sent, [])
# Not subscribed: the event stays queued for a later card session.
self.assertEqual(self.bip.take_pending_events(),
[{'channel': 1, 'status': 1, 'info': 0x05}])
if __name__ == '__main__':
unittest.main()
def test_peer_close_reported_after_buffer_drained(self):
# A dropped link must not be signalled while server data still waits
# to be fetched: the card would abort the fetch mid-record. Drain
# first, then report.
srv = socket.socket()
srv.bind(('127.0.0.1', 0))
srv.listen(1)
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', srv.getsockname()[1])
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
conn, _ = srv.accept()
conn.sendall(b'response-bytes')
conn.close()
ch = bip.channels[cid]
for _ in range(40):
ch.pump()
if ch.rx and ch.peer_closed:
break
time.sleep(0.05)
self.assertTrue(ch.rx)
self.assertTrue(ch.peer_closed)
# Partial fetch: the link-dropped event must still be withheld.
bip.receive(cid, 5)
self.assertEqual(bip.take_pending_events(), [])
# Remaining bytes fetched: the event is reported now.
bip.receive(cid, 64)
self.assertEqual(bip.take_pending_events(),
[{'channel': cid, 'status': cid, 'info': 0x05}])
bip.close(cid)
finally:
srv.close()
def test_receive_data_tlv_long_form_length(self):
# A >127-byte channel data TLV must use the BER long form (0x81 len),
# as the reference terminal traces do (`36 81 ed` for 237 bytes).
import types
server = __import__('pysim_otaman_server.server', fromlist=['x'])
big = bytes(range(256)) * 1 # 256 bytes; take a slice below
ch = types.SimpleNamespace(rx=bytearray(b'\xAA' * 237))
class FakeBip:
def __init__(self): self.channels = {1: ch}
def receive(self, cid, n):
data = bytes(ch.rx[:min(n, len(ch.rx))]); del ch.rx[:len(data)]; return data
def available(self, cid): return len(ch.rx)
def log(self, *a, **k): pass
old = server._BIP
server._BIP = FakeBip()
try:
raw = bytes.fromhex('d00c8103014200820281213701ed')
tr = server._handle_bip_command(None, 1, 0x42, 0, raw, None, 0x21)
self.assertIn(b'\x36\x81\xed' + b'\xAA' * 237, tr)
finally:
server._BIP = old
+39
View File
@@ -87,3 +87,42 @@ class TestMenuSendResponse(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class TestFinishPendingMenu(unittest.TestCase):
def make_server(self):
return types.SimpleNamespace(
stk_pending={'type': 'select_item', 'cmd_num': 1, 'cmd_type': 0x24,
'dev_src': 0x81, 'dev_dst': 0x83, 'items': []},
menu_active=True, scc=None)
def make_scc(self, sent, sw='9000'):
return types.SimpleNamespace(
cat_cla='80',
_tp=types.SimpleNamespace(send_apdu=lambda h: (sent.append(h) or ('', sw))))
def test_no_pending_is_noop(self):
sent = []
S._finish_pending_menu(types.SimpleNamespace(stk_pending=None), self.make_scc(sent))
self.assertEqual(sent, [])
def test_pending_finished_with_cancel_tr(self):
server = self.make_server()
sent = []
scc = self.make_scc(sent)
server.scc = scc
S._finish_pending_menu(server, scc)
self.assertEqual(len(sent), 1)
tr = sent[0]
self.assertTrue(tr.startswith('801400000d'), tr)
self.assertIn('83021000', tr) # general result 0x10 = cancel
self.assertIsNone(server.stk_pending)
self.assertFalse(server.menu_active)
def test_91xx_answer_drains_chain(self):
server = self.make_server()
scc = self.make_scc([], sw='9120')
server.scc = scc
with mock.patch.object(S, '_handle_proactive_chain') as chain:
S._finish_pending_menu(server, scc)
chain.assert_called_once_with(scc, '9120')
+425 -2
View File
@@ -7,6 +7,7 @@ No live/sample card keys and no ICCIDs appear here.
"""
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
@@ -25,6 +26,7 @@ from pysim_otaman_server.server import (
_decode_por,
_decode_tr,
_log_proactive,
_max_load_block_size,
_ota_reference,
_record_tr,
_spi_from_bytes,
@@ -175,6 +177,29 @@ class TestOtaReference(unittest.TestCase):
self.assertEqual(out, AES_REFERENCE_VECTORS[('1e', '19')])
self.assertEqual(spi['counter'], 'counter_must_be_lower')
def test_max_load_block_size_fits_one_sms(self):
# LOAD blocks are too large for SCP80 at the 240-byte default (pySim
# refuses a secured packet above 140 octets), so the helper finds the
# largest payload that still encodes into a single SMS.
mx = _max_load_block_size('16', '01', '15', '15', 'b00000',
'0000000001', K, K)
self.assertGreater(mx, 0)
self.assertLessEqual(mx, 240)
def load_apdu(n):
return '80E80000%02X%s00' % (n, '00' * n)
out, _ = _ota_reference('16', '01', '15', '15', 'b00000',
'0000000001', load_apdu(mx), K, K)
self.assertLessEqual(len(out) // 2, 140)
with self.assertRaises(ValueError):
_ota_reference('16', '01', '15', '15', 'b00000',
'0000000001', load_apdu(mx + 1), K, K)
def test_max_load_block_size_respects_the_requested_cap(self):
mx = _max_load_block_size('16', '01', '15', '15', 'b00000',
'0000000001', K, K, requested=50)
self.assertLessEqual(mx, 50)
self.assertGreater(mx, 0)
class TestDecodePor(unittest.TestCase):
def test_plaintext_no_cc_synthetic(self):
@@ -252,6 +277,17 @@ class TestProactiveDecode(unittest.TestCase):
srv._PROACTIVE_SESSION_START = 1234.0
srv._PLI_DATA[0x00] = '93055210011000'
@staticmethod
def _cmd_raw(cmd_type, qualifier, extras=b''):
"""A D0-wrapped proactive command (header TLVs + extras)."""
body = (bytes([0x81, 0x03, 0x01, cmd_type, qualifier])
+ bytes([0x82, 0x02, 0x83, 0x81]) + extras)
return bytes([0xD0, len(body)]) + body
@staticmethod
def _decoded(cmd_type, raw, qualifier=None):
return {d['label']: d['value'] for d in _decode_cmd(cmd_type, raw, qualifier)}
def test_decode_cmd_poll_interval(self):
r = _decode_cmd(0x03, bytes.fromhex('d00d8103010300820283818402011e'), None)
self.assertEqual(r, [{'label': 'Interval', 'value': '30 s'}])
@@ -261,13 +297,142 @@ class TestProactiveDecode(unittest.TestCase):
self.assertEqual(r, [{'label': 'Events', 'value': 'Call connected'}])
def test_decode_cmd_send_short_message(self):
r = _decode_cmd(0x13, bytes.fromhex('d0158103011300820283818b0b916106152670f900a35f020101'), None)
self.assertEqual(r, [{'label': 'SMS TPDU', 'value': '916106152670f900a35f02'}])
# SEND SHORT MESSAGE with an SMS-SUBMIT TPDU carrying GSM-7 text.
tpdu = bytes.fromhex('010006912143F5000005E8329BFD06')
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['Type'], 'SMS-SUBMIT')
self.assertEqual(r['TP-MR'], '0')
self.assertEqual(r['TP-DA'], '12345')
self.assertEqual(r['TP-PID'], '0x00')
self.assertEqual(r['TP-DCS'], '0x00')
self.assertEqual(r['TP-UDL'], '5')
self.assertEqual(r['Text'], 'hello')
self.assertEqual(r['SMS TPDU'], tpdu.hex().upper())
def test_decode_cmd_send_short_message_udh_8bit(self):
# UDHI + concatenation IE (16-bit ref) + 8-bit text data.
udh = bytes.fromhex('0608040001020341 42'.replace(' ', ''))
tpdu = (bytes.fromhex('4100' '06912143F5' '00' '04' '09') + udh)
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['Concat (16-bit ref)'], '1, part 2/3')
self.assertEqual(r['Text'], 'AB')
def test_decode_cmd_send_short_message_ucs2(self):
text = 'Тест'.encode('utf-16-be')
tpdu = (bytes.fromhex('0100' '06912143F5' '00' '08' '%02X' % len(text))
+ text)
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['TP-DCS'], '0x08')
self.assertEqual(r['Text'], 'Тест')
def test_decode_cmd_send_short_message_secured_packet(self):
# PID 0x7F = SIM data download: the UD is a secured packet (TS 31.115).
tpdu = bytes.fromhex('0100' '06912143F5' '7F' 'F6' '03' 'AABBCC')
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['TP-PID'], '0x7F (SIM data download)')
self.assertEqual(r['Secured packet (TS 31.115)'], '3 bytes: AABBCC')
def test_decode_cmd_send_short_message_malformed_falls_back(self):
# A malformed/garbage TPDU must not raise: the raw hex line remains.
raw = bytes.fromhex('d0158103011300820283818b0b916106152670f900a35f020101')
r = self._decoded(0x13, raw)
self.assertEqual(r['SMS TPDU'], '916106152670F900A35F02')
def test_decode_cmd_pli_qualifier_name(self):
r = _decode_cmd(0x26, b'\xd0', 0x00)
self.assertTrue(r[0]['value'].startswith('Location Information (MCC, MNC, LAC/TAC, Cell ID)'))
def test_decode_cmd_pli_all_standard_qualifiers_named(self):
# TS 102 223 V18.3.0 (PLI qualifier coding): names must exist even
# without a special data decoder, e.g. ESN (07) and MEID (0B).
cases = {
0x07: 'ESN',
0x0B: 'MEID',
0x1A: 'Supported Radio Access Technologies',
0x05: 'Reserved for GSM',
}
for qualifier, name in cases.items():
r = _decode_cmd(0x26, b'\xd0', qualifier)
self.assertIn(name, r[0]['value'], 'qualifier 0x%02X' % qualifier)
def test_decode_cmd_timer_management_start(self):
# TS 102 223 6.6.21/8.37/8.38: start timer 3 for 14:07:32
raw = bytes.fromhex('d011810301270082028182a40103a503417023')
self.assertEqual(_decode_cmd(0x27, raw, 0x00), [
{'label': 'Action', 'value': 'Start'},
{'label': 'Timer', 'value': '3'},
{'label': 'Value', 'value': '14:07:32'},
])
def test_decode_cmd_timer_management_plain_tags(self):
# Cards may use the plain (non comprehension-required) tag variant.
raw = bytes.fromhex('d00c010301270102028182240103')
self.assertEqual(_decode_cmd(0x27, raw, 0x01), [
{'label': 'Action', 'value': 'Deactivate'},
{'label': 'Timer', 'value': '3'},
])
def test_decode_cmd_open_channel_cr_tags(self):
# Same OPEN CHANNEL as the reference traces, but with CR-set TLVs.
raw = bytes.fromhex(
'd02b8103014001820281828500b50103b9020200c70b076d656761666f6e2e7275'
'bc03021f90be05217f000001')
r = _decode_cmd(0x40, raw, 0x01)
self.assertIn({'label': 'Bearer', 'value': '0x03'}, r)
self.assertIn({'label': 'Buffer size', 'value': '512'}, r)
self.assertIn({'label': 'APN', 'value': 'megafon.ru'}, r)
self.assertIn({'label': 'Destination', 'value': '127.0.0.1'}, r)
self.assertIn({'label': 'Transport', 'value': 'TCP client port 8080'}, r)
def test_decode_cmd_bip_channel_from_device_ids(self):
# Real trace: SEND DATA carries the channel in the device identities
# (source UICC 0x81, destination Channel 1 0x21).
raw = bytes.fromhex('d00e8103014301820281213701013603aabbcc')
r = _decode_cmd(0x43, raw, 0x01)
self.assertEqual(r[0], {'label': 'Channel', 'value': '1'})
self.assertEqual(r[1], {'label': 'Data bytes', 'value': '3'})
def test_parse_proactive_header_plain_tags(self):
import pysim_otaman_server.server as srv
raw = bytes.fromhex('d00c010301270102028182240103')
self.assertEqual(srv._parse_proactive_header(raw), (1, 0x27, 0x81, 0x82, 0x01))
def test_default_handler_logs_timer_management(self):
# pySim's auto-handler path: the parsed command object (not the empty
# collection) is re-encoded for the log and used for the response.
import pysim_otaman_server.server as srv
from pySim.cat import ProactiveCommand
from pySim.utils import h2b
srv._PROACTIVE_LOG.clear()
handler = srv._DefaultProactiveHandler()
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b('d011810301270082028182a40103a503417023'))
ti = handler.receive_fetch_raw(pcmd, parsed)
tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertTrue(tr.startswith('810301270082028281830100'), tr)
entry = srv._PROACTIVE_LOG[-1]
self.assertEqual(entry['type_hex'], '27')
self.assertEqual(entry['type_name'], 'TIMER MANAGEMENT')
self.assertEqual(entry['tr_result'], '00')
def test_default_handler_pli_includes_dict_data(self):
import pysim_otaman_server.server as srv
from pySim.cat import ProactiveCommand
from pySim.utils import h2b
srv._PROACTIVE_LOG.clear()
srv._PLI_DATA[0x00] = '93055210011000'
handler = srv._DefaultProactiveHandler()
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b('d00d810301260082028182'))
ti = handler.receive_fetch_raw(pcmd, parsed)
tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertIn('93055210011000', tr)
self.assertEqual(srv._PROACTIVE_LOG[-1]['tr_hex'], '93055210011000')
def test_decode_cmd_empty_raw(self):
self.assertEqual(_decode_cmd(0x26, b'', None), [])
self.assertEqual(_decode_cmd(0x03, None, None), [])
@@ -360,6 +525,122 @@ class TestProactiveDecode(unittest.TestCase):
self.assertNotIn('tr_result', entry)
class TestEventDownload(unittest.TestCase):
"""ENVELOPE (EVENT DOWNLOAD) assembly, TS 102 223 7.5.11."""
def _send(self, event_type, event_data):
import pysim_otaman_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
class Scc:
cat_cla = '80'
_tp = Tp()
data, sw = srv._send_event_download(Scc(), event_type, event_data)
return calls[0], sw
def test_channel_status_event(self):
# Event list + device identities + Channel status (8.56): channel 2,
# link established, info 05 = link dropped.
apdu, sw = self._send(0x0A, bytes.fromhex('b8028205'))
self.assertEqual(sw, '9000')
self.assertEqual(apdu, '80c200000dd60b99010a82028281b8028205')
def test_event_without_data(self):
apdu, sw = self._send(0x05, None)
self.assertEqual(sw, '9000')
self.assertEqual(apdu, '80c2000009d60799010582028281')
class TestTimerManagement(unittest.TestCase):
"""Terminal side of TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4).
The start vector is the live card's: timer 1, 60 s."""
START = bytes.fromhex('d011810301270082028182a40101a503001000')
def tearDown(self):
import pysim_otaman_server.server as srv
srv._timer_cancel()
def test_hms_bcd_roundtrip(self):
import pysim_otaman_server.server as srv
self.assertEqual(srv._hms_bcd(60).hex(), '001000')
self.assertEqual(srv._hms_bcd(3723).hex(), '102030')
self.assertEqual([srv._bcd_swap(b) for b in srv._hms_bcd(3723)], [1, 2, 3])
def test_start_returns_result_only_and_arms_timer(self):
import pysim_otaman_server.server as srv
tr = srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270082028281030100')
remaining = srv._timer_remaining(1)
self.assertTrue(55 <= remaining <= 60, remaining)
def test_get_returns_remaining_value(self):
import pysim_otaman_server.server as srv
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270282028281a40101a503001000030100')
def test_deactivate_stops_and_reports_value(self):
import pysim_otaman_server.server as srv
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
tr = srv._handle_timer_command(1, 0x27, 0x01, self.START, 0x81, 0x82)
self.assertTrue(tr.hex().startswith('8103012701'), tr.hex())
self.assertIn('a40101a503001000', tr.hex())
self.assertIsNone(srv._timer_remaining(1))
def test_get_on_stopped_timer_is_contradiction(self):
import pysim_otaman_server.server as srv
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270282028281030124')
def test_timer_expiration_envelope(self):
import pysim_otaman_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
ref = types.SimpleNamespace(
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
with mock.patch.object(srv, '_server_ref', ref):
with mock.patch.object(srv, '_CARD_CONNECTED', True):
srv._timer_expired(1, 60)
# D7 0C: device identities (terminal -> UICC), Timer id A4, value A5
self.assertEqual(calls, ['80c200000ed70c82028281a40101a503001000'])
def test_cancelled_timer_does_not_report(self):
import pysim_otaman_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
ref = types.SimpleNamespace(
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
with mock.patch.object(srv, '_server_ref', ref):
with mock.patch.object(srv, '_CARD_CONNECTED', True):
srv._timer_fire(1, 60) # never started/cancelled
self.assertEqual(calls, [])
def test_decode_tr_timer(self):
tr = bytes.fromhex('810301270082028281a40101a503001000030100')
data = _tr_data_only(tr).hex()
r = _decode_tr('27', '00', data)
self.assertEqual(r, [{'label': 'Timer', 'value': '1'},
{'label': 'Remaining', 'value': '00:01:00'}])
class TestExpandedRemoteResponse(unittest.TestCase):
"""Expanded Remote Response parsing (TS 102 226 §5.2.2)."""
@@ -645,3 +926,145 @@ class TestSmsReassembly(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class CapApduSequenceTest(unittest.TestCase):
"""RAM APDU sequence shared by the SCP80 and SCP81 install paths."""
def _mini_cap(self):
import io, zipfile
# Header: tag(1) size(2) magic(4) minor(1) major(1) flags(1)
# pkg minor(1) pkg major(1) aid_len(1) aid(N)
header = (b'\x01\x00\x11' + b'\xde\xca\xff\xed' + b'\x00\x01\x00' +
b'\x00\x01' + b'\x06' + b'\xa0\x00\x00\x01\x00\x01')
# Applet: tag(1) size(2) count(1) aid_len(1) module_aid(N) offset(2)
applet = (b'\x03\x00\x0a\x01\x05' + b'\xa0\x00\x00\x01\x00' + b'\x00\x08')
buf = io.BytesIO()
zf = zipfile.ZipFile(buf, 'w')
zf.writestr('pkg/Header.cap', header)
zf.writestr('pkg/Applet.cap', applet)
zf.close()
return buf.getvalue().hex().upper()
def test_cap_parse(self):
from pysim_otaman_server.server import _cap_parse
loadfile_aid, module_aid, data = _cap_parse(self._mini_cap())
self.assertEqual(loadfile_aid, 'A00000010001')
self.assertEqual(module_aid, 'A000000100')
# Header then Applet, per the CAP component order.
self.assertTrue(data.startswith('010011DECAFFED'))
self.assertIn('03000A01', data)
def test_sequence_install_load_install(self):
from pysim_otaman_server.server import _cap_apdu_sequence
seq = _cap_apdu_sequence('A00000010001', 'A000000100', 'AABBCCDD')
# INSTALL [for load]: lv(pkg aid) + lv(ISD) + 000000
self.assertEqual(seq[0],
'80E6020013' + '06A00000010001' + '08A000000003000000' + '000000' + '00')
# One LOAD block (small payload, last -> P1=0x80, P2=0)
self.assertEqual(seq[1][:8], '80E88000')
self.assertTrue(seq[1].endswith('00'))
# INSTALL [for install]: C9 00 install params appended to the lv chain
self.assertTrue(seq[2].startswith('80E60C00'))
self.assertIn('06A00000010001' + '05A000000100' + '05A000000100' + '0100', seq[2])
def test_load_blocks_split_and_counter(self):
from pysim_otaman_server.server import _cap_apdu_sequence, _ber_len as _ber_len_lower
data = ''.join('%02X' % (i % 256) for i in range(700))
seq = _cap_apdu_sequence('A00000010001', 'A000000100', data)
self.assertEqual(len(seq), 5) # INSTALL + 3 LOAD + INSTALL
self.assertEqual(seq[1][:8], '80E80000')
self.assertEqual(seq[2][:8], '80E80001')
self.assertEqual(seq[3][:8], '80E88002') # last block: P1=0x80
# The blocks are consecutive chunks and reassemble the load file TLV
# byte-for-byte (a shifted/overlapping split fails the card mid-load).
def payload(apdu):
lc = int(apdu[8:10], 16)
return apdu[10:10 + lc * 2]
joined = payload(seq[1]) + payload(seq[2]) + payload(seq[3])
self.assertTrue(joined.startswith('C482'))
expected = 'C4' + _ber_len_lower(700) + data # 700 = 0x2BC
self.assertEqual(joined.upper(), expected.upper())
self.assertEqual(int(seq[3][8:10], 16), len(expected) // 2 - 480)
def test_custom_block_size_splits_into_more_blocks(self):
# A smaller block size (SCP80: fit one SMS) slices the load file TLV
# into consecutive chunks of that size, the last block marked P1=0x80
# with the block counter in P2.
from pysim_otaman_server.server import _cap_apdu_sequence
data = ''.join('%02X' % (i % 256) for i in range(700)) # TLV = 704 bytes
seq = _cap_apdu_sequence('A00000010001', 'A000000100', data, block_size=100)
self.assertEqual(len(seq), 10) # INSTALL + 8 LOAD + INSTALL
loads = seq[1:-1]
self.assertEqual(len(loads), 8)
for i, apdu in enumerate(loads):
self.assertEqual(apdu[:8], '80E8%s%02X' % ('80' if i == 7 else '00', i))
def payload(apdu):
lc = int(apdu[8:10], 16)
return apdu[10:10 + lc * 2]
joined = ''.join(payload(a) for a in loads)
self.assertEqual(len(joined) // 2, 704) # C4 82 02BC + 700 data bytes
self.assertTrue(joined.startswith('C482'))
self.assertEqual(int(loads[0][8:10], 16), 100)
self.assertEqual(int(loads[-1][8:10], 16), 4) # 704 = 7*100 + 4
def test_gen_install_returns_the_apdu_list(self):
# /api/scp81/gen-install: build the INSTALL/LOAD/INSTALL list for a
# .cap without touching any listener or script state.
from pysim_otaman_server.server import _scp81_gen_install
resp = _scp81_gen_install({'cap_hex': self._mini_cap(), 'privileges': '01'})
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['load_file_aid'], 'A00000010001')
self.assertEqual(resp['module_aid'], 'A000000100')
self.assertEqual(len(resp['apdus']), 3)
self.assertTrue(resp['apdus'][0].startswith('80E60200'))
self.assertTrue(resp['apdus'][1].startswith('80E88000'))
self.assertTrue(resp['apdus'][2].startswith('80E60C00'))
self.assertNotIn('queued', resp) # generation only, no queueing
def test_gen_install_rejects_bad_input(self):
from pysim_otaman_server.server import _scp81_gen_install
self.assertFalse(_scp81_gen_install({})['ok'])
resp = _scp81_gen_install({'cap_hex': '00'})
self.assertFalse(resp['ok'])
self.assertIn('cap parse failed', resp['error'])
class TerminalProfileTest(unittest.TestCase):
"""Runtime TERMINAL PROFILE: hex validation and re-send."""
def test_validate_tp_hex(self):
from pysim_otaman_server.server import _validate_tp_hex
self.assertEqual(_validate_tp_hex('ff 00 80'), ('FF0080', None))
self.assertEqual(_validate_tp_hex('80FF'), ('80FF', None))
for bad in ('', ' ', 'F', 'XYZ', 'FF0', 'FF' * 256):
h, err = _validate_tp_hex(bad)
self.assertIsNone(h, bad)
self.assertTrue(err, bad)
def test_resend_terminal_profile_resets_state_and_sends(self):
import types
from pysim_otaman_server import server as srv
server_obj = types.SimpleNamespace(
terminal_profile='FF00', stk_pending={'type': 'display_text'},
menu_active=True, event_list=[0x03], sim_menu='old')
seen = []
old_send = srv._send_terminal_profile
def fake_send(scc, tp):
seen.append(tp)
return 'menu', [0x09]
srv._send_terminal_profile = fake_send
try:
resp = srv._resend_terminal_profile(server_obj, object())
finally:
srv._send_terminal_profile = old_send
self.assertTrue(resp['ok'])
self.assertEqual(resp['profile'], 'FF00')
self.assertEqual(seen, ['FF00'])
self.assertIsNone(server_obj.stk_pending)
self.assertFalse(server_obj.menu_active)
self.assertEqual(server_obj.event_list, [0x09])
self.assertEqual(server_obj.sim_menu, 'menu')
self.assertTrue(resp['menu'])
+1189
View File
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
The helpers must resolve every model-known file strictly within the requested
parent (no pySim global selectables, no probe_file model injection) and must
detach any model-unknown file that had to be probed for a custom file.
"""
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_otaman_server.server import (
_app_by_sel,
_fid4,
_file_by_sel,
_find_in_tree,
_select_path,
_select_with_parent,
)
class FakeFile:
def __init__(self, fid=None, name=None, parent=None, aid=None):
self.fid = fid
self.name = name
self.parent = parent
self.aid = aid
self.sfid = None
self.children = {}
def add_files(self, files):
for f in files:
f.parent = self
self.children[f.fid] = f
class FakeMF(FakeFile):
def __init__(self):
super().__init__(fid='3f00', name='MF')
self.applications = {}
class FakeLchan:
"""No .select() on purpose: any global-resolution call would fail loudly."""
def __init__(self, mf):
self.selected_file = mf
self.selects = []
self.probes = []
def select_file(self, f, app=None):
self.selected_file = f
self.selects.append(f)
def probe_file(self, fid, app=None):
self.probes.append(fid)
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
self.selected_file.add_files([f])
self.selected_file = f
def build_model():
mf = FakeMF()
gsm = FakeFile('7f20', 'DF.GSM', mf)
mf.children['7f20'] = gsm
spn = FakeFile('6f46', 'EF.SPN', gsm)
gsm.children['6f46'] = spn
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
mf.children['7f10'] = telecom
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
telecom.children['5f3a'] = tel_ph
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
mf.applications['a0000000871002'] = usim
imsi = FakeFile('6f07', 'EF.IMSI', usim)
usim.children['6f07'] = imsi
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
usim.children['5f3a'] = usim_ph
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
def setup():
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
lchan = FakeLchan(mf)
return app, lchan, mf, usim, usim_ph, gsm, spn
class FidHelpersTest(unittest.TestCase):
def test_fid4(self):
self.assertTrue(_fid4('6F07'))
self.assertFalse(_fid4('EF.IMSI'))
self.assertFalse(_fid4('6F0'))
def test_file_by_sel_matches_fid_and_name(self):
_, _, _, _, _, gsm, spn = setup()
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
self.assertIsNone(_file_by_sel(gsm, '6f07'))
def test_find_in_tree_reports_duplicates(self):
app, _, mf, _, _, _, _ = setup()
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
class ParentScopedSelectTest(unittest.TestCase):
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
app, lchan, mf, usim, usim_ph, _, _ = setup()
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
self.assertIs(target, usim_ph)
self.assertIsNone(cleanup)
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
self.assertEqual(lchan.probes, [])
def test_path_with_fids_selects_exactly(self):
app, lchan, mf, _, _, gsm, spn = setup()
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
self.assertIs(target, spn)
self.assertIsNone(cleanup)
self.assertEqual(lchan.selects, [mf, gsm, spn])
def test_path_with_aid_root_selects_application(self):
app, lchan, _, usim, _, _, _ = setup()
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
self.assertEqual(target.fid, '6f07')
self.assertEqual(lchan.selected_file.parent, usim)
def test_ambiguous_legacy_parent_selector_is_rejected(self):
app, lchan, _, _, _, _, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
_select_with_parent(lchan, '6f07', '5f3a', app)
def test_unknown_name_is_not_probed(self):
app, lchan, _, _, _, gsm, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'File not found'):
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
self.assertEqual(lchan.probes, [])
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
app, lchan, _, _, _, gsm, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'File not found'):
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
self.assertEqual(lchan.probes, [])
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
app, lchan, mf, _, _, gsm, _ = setup()
before = set(gsm.children)
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
self.assertEqual(lchan.probes, ['6f99'])
self.assertEqual(target.fid, '6f99')
self.assertIsNotNone(cleanup)
self.assertIn('6f99', gsm.children)
cleanup()
self.assertEqual(set(gsm.children), before)
self.assertIs(lchan.selected_file, mf)
def test_custom_path_segments_are_probed_and_detached(self):
app, lchan, mf, _, _, gsm, _ = setup()
before = set(gsm.children)
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
self.assertEqual(target.fid, '6f01')
cleanup()
self.assertEqual(set(gsm.children), before)
self.assertIs(lchan.selected_file, mf)
def test_model_known_selection_never_mutates_the_tree(self):
app, lchan, _, _, _, gsm, spn = setup()
before = {id(k): k for k in gsm.children}
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
self.assertEqual({id(k): k for k in gsm.children}, before)
self.assertEqual(lchan.probes, [])
if __name__ == '__main__':
unittest.main()
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Decrypt the SCP81 PSK-TLS dialog from a server log + keylog.
The server's /api/scp81/log has every TLS record of the dialog (send = card,
receive = card fetch, i.e. the server stream), and the listener can write the
TLS secrets (SSLKEYLOGFILE) when started with a "keylog" path. With the
PSK-AES128-CBC-SHA256 dialog we can derive the record keys (TLS 1.2
PRF/master secret) and decrypt the card's alerts, which are otherwise opaque.
Usage: scp81_decrypt.py <log.json> <keys.log>
"""
import hashlib
import hmac
import json
import subprocess
import sys
def p_sha256(secret, seed, length):
out = b''
a = seed
while len(out) < length:
a = hmac.new(secret, a, hashlib.sha256).digest()
out += hmac.new(secret, a + seed, hashlib.sha256).digest()
return out[:length]
def aes_cbc_decrypt(key, iv, data):
p = subprocess.run(['openssl', 'enc', '-d', '-aes-128-cbc', '-nopad',
'-K', key.hex(), '-iv', iv.hex()],
input=data, capture_output=True)
if p.returncode != 0:
raise RuntimeError(p.stderr.decode())
return p.stdout
def record_payloads(stream):
"""Walk TLS records in a byte stream, yield (type, version, payload)."""
i = 0
while i + 5 <= len(stream):
rtype, ver, ln = stream[i], stream[i + 1:i + 3], int.from_bytes(stream[i + 3:i + 5], 'big')
body = stream[i + 5:i + 5 + ln]
if len(body) < ln:
break
yield rtype, ver, body
i += 5 + ln
def find_random(stream, hs_type):
"""Return the 32-byte random of a ClientHello/ServerHello in the stream."""
for rtype, ver, body in record_payloads(stream):
if rtype != 0x16 or not body or body[0] != hs_type:
continue
hslen = int.from_bytes(body[1:4], 'big')
hs = body[:4 + hslen]
return hs[6:38]
return None
def main():
log_path, keys_path = sys.argv[1], sys.argv[2]
entries = sorted(json.load(open(log_path))['entries'], key=lambda x: x['seq'])
# Only the last TLS session: start at the final OPEN CHANNEL.
start = 0
for i, e in enumerate(entries):
if e.get('kind') == 'open':
start = i
entries = entries[start:]
client = b''
server = b''
for e in entries:
if e.get('kind') == 'send' and e.get('hex'):
client += bytes.fromhex(e['hex'])
elif e.get('kind') == 'receive' and e.get('hex'):
server += bytes.fromhex(e['hex'])
crandom = find_random(client, 0x01)
srandom = find_random(server, 0x02)
print('client_random:', crandom.hex() if crandom else None)
print('server_random:', srandom.hex() if srandom else None)
if not crandom or not srandom:
sys.exit('handshake randoms not found in log')
master = None
for line in open(keys_path):
parts = line.split()
if parts and parts[0] == 'CLIENT_RANDOM' and parts[1] == crandom.hex():
master = bytes.fromhex(parts[2])
if not master:
sys.exit('master secret not found in keylog')
print('master_secret:', master.hex())
kb = p_sha256(master, b'key expansion' + srandom + crandom, 96)
client_mac, server_mac = kb[0:32], kb[32:64]
client_key, server_key = kb[64:80], kb[80:96]
print('client_key: %s server_key: %s' % (client_key.hex(), server_key.hex()))
names = {0x15: 'alert', 0x16: 'handshake', 0x17: 'appdata', 0x14: 'ccs'}
for who, stream, key in (('card', client, client_key),
('server', server, server_key)):
app_seq = 0
for rtype, ver, body in record_payloads(stream):
if rtype not in (0x15, 0x17) or len(body) < 16 + 32:
continue
iv, ct, mac = body[:16], body[16:-32], body[-32:]
try:
pt = aes_cbc_decrypt(key, iv, ct)
except RuntimeError as e:
print('%s seq%d %s: decrypt failed: %s' % (who, app_seq, names.get(rtype), e))
app_seq += 1
continue
# verify the record MAC (seq, type, version, len, plaintext)
h = hmac.new(client_mac if who == 'card' else server_mac,
app_seq.to_bytes(8, 'big') + bytes([rtype]) + ver +
len(pt).to_bytes(2, 'big') + pt, hashlib.sha256).digest()
mac_ok = hmac.compare_digest(h, mac)
desc = ''
if rtype == 0x15 and len(pt) >= 2:
level = {1: 'warning', 2: 'fatal'}.get(pt[0], str(pt[0]))
alerts = {0: 'close_notify', 10: 'unexpected_message',
20: 'bad_record_mac', 40: 'handshake_failure',
46: 'protocol_version', 47: 'illegal_parameter',
48: 'unknown_ca', 49: 'access_denied',
50: 'decode_error', 51: 'decrypt_error',
80: 'internal_error', 90: 'user_canceled',
100: 'no_renegotiation', 110: 'unsupported_extension',
112: 'unrecognized_name'}
desc = 'ALERT %s %s' % (level, alerts.get(pt[1], pt[1]))
print('%s seq%d %-9s mac_ok=%s pt=%s %s'
% (who, app_seq, names.get(rtype), mac_ok, pt[:48].hex(), desc))
app_seq += 1
if __name__ == '__main__':
main()