From 6e4eb5932ca114f189679f3d9976bc096e8dd3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 2 Oct 2026 00:40:09 +0300 Subject: [PATCH] Expanded listings follow the command format; registry gets a format fallback The TCA loader's Explore traces settle the format question: it sends the same P2=00 listing queries wrapped and unchained (`AA0A2208 80F24000024F0000`, pages `...01024F0000`) - no GET RESPONSE anywhere - while the live card needs the chain and answers 6700 to the wrapped unchained form; and the live ISD refuses the compact P2=02 registry query (6700) while serving it wrapped (`AA11220F 80F2400209 4F005C054F9F70C5CC00` -> 6310 + entries with CE versions). Both the listing and the registry queries now follow the operation's command format and fall back across formats. - ramFormatIsExpanded(fmt) + ramFmtLadder(runFmt, working) (pure): the run's format first, then expanded, expanded-ae and compact as the last resort; a format that served data is tried first and never repeated. - ramGetStatusApdu(p1, p2, fmt): the chain exists in compact only (TS 102 226 5.2.1.1) - an expanded run sends the bare query. - paginate: the P2=00 listing follows the run's format and pins it per send, so a GET RESPONSE can never ride inside an expanded packet. - expandedPass: each registry form goes through the format ladder; the chained 61xx retry is compact-only (it was guarded for 'expanded' but not 'expanded-ae'); the serving format is remembered for the remaining listings and named in the report ([AA]/[AE 80]); ramExpandedGroups carries it. - Tests: the helper + ladder vectors, the unchained expanded listing builder (both codings), the fallback/chain source guards, the report marker. - Help EN/RU, the repo AGENTS.md; sw cache simple-v353. --- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 225 ++++++++++++++++++++++++------------- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 53 +++++++++ 5 files changed, 203 insertions(+), 81 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 4622540..f8772dc 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -314,7 +314,7 @@

Кнопка Проверить в pySim сверяет собранный пакет с эталонной реализацией OtaDialectSms.encode_cmd. Кнопка Отправить на карту доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется конкатенированной загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку «Разбор R-APDU» (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика — b5 b4 ≠ 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет.

3.2 RAM

-

Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR пресета, по умолчанию 000000). Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Селектор Формат фиксирует формат удалённой команды: Авто (определение) опрашивает read-only команду GET STATUS в компактном виде и в обоих expanded-кодированиях и оставляет первое, которое карта выполнила; Компактный отправляет голую C-APDU; Expanded (AA, определённая длина) / Expanded (AE 80, неопределённая длина) заворачивают её в шаблон Command Scripting из TS 102 226 §5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага).

+

Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR пресета, по умолчанию 000000). Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Селектор Формат фиксирует формат удалённой команды: Авто (определение) опрашивает read-only команду GET STATUS в компактном виде и в обоих expanded-кодированиях и оставляет первое, которое карта выполнила; Компактный отправляет голую C-APDU; Expanded (AA, определённая длина) / Expanded (AE 80, неопределённая длина) заворачивают её в шаблон Command Scripting из TS 102 226 §5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага). Запросы листингов и реестра следуют этому формату: компактные листинги несут сцепленный GET RESPONSE, а expanded-прогон отправляет те же запросы P2=00 обёрнутыми и без цепочки (форма эталонного загрузчика TCA; Le=’00’ возвращает листинг сразу), и запрос реестра, отклонённый картой в текущем формате, повторяется в остальных — отчёт называет тот, который сработал.

Операции

diff --git a/frontend/help.html b/frontend/help.html index aabd9d3..31f52d0 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -313,7 +313,7 @@

A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference OtaDialectSms.encode_cmd. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a concatenated download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the R-APDU parser panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet. A low-counter verdict (cntr_low, or a warning ENVELOPE answer that still carries such a PoR) is not recoverable by retrying: every SCP80/RAM operation stops immediately. The card never reports its counter (the PoR’s CNTR field is a copy of the packet’s), so the notice offers a bounded Sync counter probe — a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored — and a Go to preset button for raising the counter by hand; nothing else is written automatically.

3.2 RAM

-

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 Cards tab provides the SPI, keys, TAR, and counter (RAM uses the preset's ISD TAR, 000000 by default). The Keyset selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The Format selector pins the remote command format: Auto (detect) probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, Compact sends the bare C-APDU, and Expanded (AA, definite length) / Expanded (AE 80, indefinite length) wrap it in the TS 102 226 §5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure).

+

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 Cards tab provides the SPI, keys, TAR, and counter (RAM uses the preset's ISD TAR, 000000 by default). The Keyset selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The Format selector pins the remote command format: Auto (detect) probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, Compact sends the bare C-APDU, and Expanded (AA, definite length) / Expanded (AE 80, indefinite length) wrap it in the TS 102 226 §5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure). The listing and registry queries follow that format: compact listings keep the chained GET RESPONSE, while an expanded run sends the same P2=00 queries wrapped and unchained (the reference TCA loader’s form, Le=’00’ returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats — the report names the one that served it.

Operations

diff --git a/frontend/index.html b/frontend/index.html index 8be371f..c345b62 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9341,7 +9341,7 @@ function ramExpandedGroups(report) { let g = groups.find(x => x.label === r.label); if (!g) { g = { p1: r.p1 || '', label: r.label, form: null, entries: 0, score: 0, - sw: '', compact: false, refused: false }; + sw: '', compact: false, refused: false, fmt: null }; groups.push(g); } g.compact = g.compact || !!r.compact; @@ -9354,6 +9354,7 @@ function ramExpandedGroups(report) { g.form = r.form; g.entries = r.entries || 0; g.score = score; + g.fmt = r.fmt || null; } } else { g.refused = true; @@ -9383,10 +9384,15 @@ function ramExpandedReport(report) { if (!groups.length) return ''; const formName = f => f === 'tags' ? t('tag list + EA') : (f === 'noea' ? t('tag list') : (f === 'lean' ? t('lean tag list') : t('no tag list'))); + // The serving command format is named when it is one of the expanded + // codings: some cards refuse the compact P2=02 query and serve it only + // inside the TS 102 226 scripting template (5.2.1) + const fmtName = f => f === 'expanded' ? 'AA' : (f === 'expanded-ae' ? 'AE 80' : ''); const parts = []; RAM_EXPANDED_FORMS.forEach(f => { const labels = groups.filter(g => g.form === f) - .map(g => g.label + (g.more ? ' (' + t('first page only') + ')' : '')); + .map(g => g.label + (g.more ? ' (' + t('first page only') + ')' : '') + + (ramFormatIsExpanded(g.fmt) ? ' [' + fmtName(g.fmt) + ']' : '')); if (labels.length) parts.push(formName(f) + ' (' + labels.join(', ') + ')'); }); groups.forEach(g => { @@ -9599,9 +9605,12 @@ async function ramExplore(sp) { const mem = { appCount: null, freeNV: null, freeV: null }; const isd = [], apps = [], elfs = []; // Expanded-registry state for this run: the query form the card accepted - // (remembered and tried first for the later listings) and the per-listing - // outcome report shown in the explorer. + // (remembered and tried first for the later listings), the command format + // the registry queries needed (null = the run's format; the live ISD + // answers the compact P2=02 query with 6700 and serves it wrapped) and the + // per-listing outcome report shown in the explorer. let expandedForm = null; + let expandedFmt = null; const expandedReport = []; // Low counter: the card rejected a packet whose counter is not above its // own - a retry cannot help, so the run stops immediately and the user is @@ -9618,87 +9627,111 @@ async function ramExplore(sp) { // the remembered form first, then the others. The ISD gets a single // attempt - cards commonly answer 6A88/6A86 there ("no data to report", // GPPro), which must not poison the detection for the apps/ELF queries. - // A '61xx' answer means the card holds the response data (fetched with - // GET RESPONSE); a '6310' means more occurrences (re-issued as a - // next-occurrence page, P2.b1=1) - both are chained here, so an answer is - // only called empty or refused when it really is. Returns - // {form, parsed, sw, entries}; form is null when no form produced data. + // Each form is tried in the run's command format first, then in the two + // expanded codings and compact as the last resort: the live ISD refuses + // the compact P2=02 query with 6700 and serves it wrapped, while other + // cards do the opposite - the format that works is remembered for the + // remaining listings. A '61xx' answer means the card holds the response + // data - only the compact command string may chain a GET RESPONSE, the + // expanded format must not (TS 102 226 5.2.1.1); a '6310' means more + // occurrences (re-issued as a next-occurrence page, P2.b1=1). Returns + // {form, parsed, sw, entries, fmt}; form is null when no form produced + // data. async function expandedPass(p1, label, parser, spi2) { let forms = expandedForm ? [expandedForm].concat(RAM_EXPANDED_FORMS.filter(f => f !== expandedForm)) : RAM_EXPANDED_FORMS.slice(); if (p1 === '80') forms = [expandedForm || 'tags']; + const runFmt = sp.ram_format || 'auto'; let lastSw = ''; let lastTransport = false; + let stopPass = false; for (const form of forms) { - if (cntrLow) break; - let data = ''; - let sw = ''; - let accepted = true; - let transportError = false; - const sendOne = async (apdu, what) => { - ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...'); - const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 })); - if (res.ram_format) sp.ram_format = res.ram_format; - if (res.final_cntr) cntr = res.final_cntr; - else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr); - if (!res.success || !res.por || !spPorAccepted(res.por)) { - accepted = false; - // a request/transport failure is not a remote status word - transportError = !res.por; - sw = res.por ? res.por.response_status : (res.error || t('no data')); - return ''; - } - sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase(); - return res.por.decoded ? (res.por.decoded.last_response_data || '') : ''; - }; - let page = 0; - let promised = false; - while (accepted && page++ < 8) { - const apdu = ramExpandedQueryApdu(p1, form, page > 1); - const what = page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')'; - data += await sendOne(apdu, what); - if (cntrLow || !accepted) break; - // '61xx': the card holds the response data - the GET RESPONSE - // must ride in the same compact command string (a standalone - // one answers 6700 on the live card); the expanded AA/22 - // format has no command string, so there is no retry there. - const pending = /^61[0-9A-F]{2}$/.test(sw); - if (pending && sp.ram_format !== 'expanded') { - data += await sendOne(ramChainGetResponse(apdu), what + ' + GET RESPONSE'); + if (cntrLow || stopPass) break; + const fmts = ramFmtLadder(runFmt, expandedFmt); + for (const fmt of fmts) { + if (cntrLow) break; + let data = ''; + let sw = ''; + let accepted = true; + let transportError = false; + let usedFmt = fmt || runFmt; + const sendOne = async (apdu, what) => { + ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...'); + const params = Object.assign({}, sp, { cntr, spi2 }); + if (fmt) params.ram_format = fmt; + const res = await ramSendOta(apdu, params); + usedFmt = res.ram_format || fmt || sp.ram_format || usedFmt; + // an explicit fallback format is not adopted as the run's + // format - the selected/pinned format keeps applying to the + // other passes + if (!fmt && res.ram_format) sp.ram_format = res.ram_format; + if (res.final_cntr) cntr = res.final_cntr; + else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr); + if (!res.success || !res.por || !spPorAccepted(res.por)) { + accepted = false; + // a request/transport failure is not a remote status word + transportError = !res.por; + sw = res.por ? res.por.response_status : (res.error || t('no data')); + return ''; + } + sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase(); + return res.por.decoded ? (res.por.decoded.last_response_data || '') : ''; + }; + let page = 0; + let promised = false; + while (accepted && page++ < 8) { + const apdu = ramExpandedQueryApdu(p1, form, page > 1); + const what = (page > 1 ? 'P2=03 (' : 'P2=02 (') + form + + (fmt ? ', ' + fmt : '') + ')'; + data += await sendOne(apdu, what); if (cntrLow || !accepted) break; + // '61xx': the card holds the response data - the GET RESPONSE + // must ride in the same compact command string (a standalone + // one answers 6700 on the live card); the expanded format + // has no command string, so there is no retry there. + const pending = /^61[0-9A-F]{2}$/.test(sw); + if (pending && !ramFormatIsExpanded(usedFmt)) { + data += await sendOne(ramChainGetResponse(apdu), what + ' + GET RESPONSE'); + if (cntrLow || !accepted) break; + } + // the ISD is a single occurrence (GP 11.4.2.2); '6310' means + // more occurrences, and a '61xx' page may mask that signal + // (the GET RESPONSE's own SW replaces it) - try one more page + if (p1 === '80') break; + // continue only when the answer really says 'more occurrences' + // or the chained retry was accepted (a '61xx' page masks the + // 6310 signal with the GET RESPONSE's own SW); a refused retry + // must not cost another packet (v3.7.0) + if (sw !== '6310' && !(pending && ramRemoteSwOk(sw))) break; + promised = true; } - // the ISD is a single occurrence (GP 11.4.2.2); '6310' means - // more occurrences, and a '61xx' page may mask that signal - // (the GET RESPONSE's own SW replaces it) - try one more page - if (p1 === '80') break; - // continue only when the answer really says 'more occurrences' - // or the chained retry was accepted (a '61xx' page masks the - // 6310 signal with the GET RESPONSE's own SW); a refused retry - // must not cost another packet (v3.7.0) - if (sw !== '6310' && !(pending && ramRemoteSwOk(sw))) break; - promised = true; - } - // An unfulfilled 6310 only: a follow-up page that was accepted and - // ended the listing fulfils the promise. - const more = ramExpandedMore(promised, sw, page); - const parsed = data ? parser(data) : []; - if (parsed.length) { - expandedForm = form; - return { form: form, parsed: parsed, sw: sw, entries: parsed.length, - transport: transportError, more: more }; - } - lastSw = sw; - lastTransport = transportError; - if (!accepted) break; - if (ramRemoteSwOk(sw)) { - // accepted but empty: nothing to gain from another form - return { form: form, parsed: [], sw: sw, entries: 0, - transport: transportError, more: more }; + // An unfulfilled 6310 only: a follow-up page that was accepted and + // ended the listing fulfils the promise. + const more = ramExpandedMore(promised, sw, page); + const parsed = data ? parser(data) : []; + if (parsed.length) { + expandedForm = form; + if (fmt) expandedFmt = fmt; + return { form: form, parsed: parsed, sw: sw, entries: parsed.length, + transport: transportError, more: more, fmt: usedFmt }; + } + lastSw = sw; + lastTransport = transportError; + if (!accepted) { + stopPass = true; + break; + } + if (ramRemoteSwOk(sw)) { + // accepted but empty: nothing to gain from another form + return { form: form, parsed: [], sw: sw, entries: 0, + transport: transportError, more: more, fmt: usedFmt }; + } + // refused: the same form in the next command format } } return { form: null, parsed: [], sw: lastSw, entries: 0, - transport: lastTransport, more: false }; + transport: lastTransport, more: false, fmt: null }; } async function paginate(p1, collector, parser, label, quiet) { @@ -9712,10 +9745,15 @@ async function ramExplore(sp) { let compactError = null; let p2 = '00'; let guard = 0; + // The P2=00 listing follows the run's command format: compact with the + // chained GET RESPONSE (the live card) or wrapped unchained (the TCA + // loader's form). The format is pinned per send, so a chain can never + // ride inside an expanded packet. + const listingFmt = ramFormatIsExpanded(sp.ram_format) ? sp.ram_format : 'compact'; while (guard++ < 32) { - const apdu = ramGetStatusApdu(p1, p2); + const apdu = ramGetStatusApdu(p1, p2, listingFmt); ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...'); - const res = await sendOta(apdu, Object.assign({}, sp, { cntr, spi2 })); + const res = await sendOta(apdu, Object.assign({}, sp, { cntr, spi2, ram_format: listingFmt })); if (cntrLow) break; // Reuse the format detected for the first packet of this run if (res.ram_format) sp.ram_format = res.ram_format; @@ -9739,7 +9777,9 @@ async function ramExplore(sp) { break; } // Defensive: a remote 61XX means the data is still pending (the - // chained GET RESPONSE normally prevents this). + // chained GET RESPONSE normally prevents this; the expanded format + // has no chain, so such a page yields nothing and the expanded TLV + // pass below provides the listing). if (sw && sw.startsWith('61')) { if (data) { const parsed61 = parser(data); @@ -9774,7 +9814,7 @@ async function ramExplore(sp) { const out = await expandedPass(p1, label, parser, spi2); expandedReport.push({ p1: p1, label: label, compact: collector.length > before, form: out.form, sw: out.sw, entries: out.entries, - transport: out.transport, more: out.more }); + transport: out.transport, more: out.more, fmt: out.fmt }); if (collector.length === before) { // Nothing from the compact pass: the expanded result is the listing collector.push(...out.parsed); @@ -12067,6 +12107,28 @@ function spCntrLow(res) { // Remote-command status words that count as success (mirrors the server's // `_ram_remote_sw_ok`): 9000 normal; 61xx more data available; 62xx/63xx // warnings; CAFE GlobalPlatform "more data available" (GET STATUS pages). +// True for the two expanded command formats (TS 102 226 5.2.1). The expanded +// format does not use GET RESPONSE (5.2.1.1): a command may never be chained +// there - the reference TCA loader sends the same P2=00 listing queries +// wrapped and unchained (Le='00' returns the listing directly), while the +// live card serves them compact with the chain. The listing builders follow +// the run's command format for exactly that reason. +function ramFormatIsExpanded(fmt) { + return fmt === 'expanded' || fmt === 'expanded-ae'; +} + +// The command-format ladder of one expanded-registry form (pure): the run's +// format first (null = no override), then the two expanded codings and +// compact as the last resort - cards differ in which one they serve (the live +// ISD refuses the compact P2=02 query and answers it wrapped). A format that +// already produced data (`working`) is tried first and never repeated. +function ramFmtLadder(runFmt, working) { + const ladder = [null, 'expanded', 'expanded-ae', 'compact'].filter((v, i, a) => + a.indexOf(v) === i && (v === null || v !== runFmt)); + if (!working) return ladder; + return [working].concat(ladder.filter(f => f !== null && f !== working)); +} + function ramRemoteSwOk(sw) { const s = (sw || '').toUpperCase(); return !!s && (s === '9000' || s === 'CAFE' || @@ -12079,8 +12141,15 @@ function ramRemoteSwOk(sw) { // payload. The expanded TLV format (P2.b2=1, P2=02/03) must NOT carry a GET // RESPONSE - it returns the E3 templates directly - so this builder is only // for the compact path. -function ramGetStatusApdu(p1, p2) { - return '80F2' + p1 + p2 + '024F0000' + 'C0000000'; +function ramGetStatusApdu(p1, p2, fmt) { + // The chained GET RESPONSE exists in the compact command format only: the + // expanded format must not carry one (TS 102 226 5.2.1.1), and the + // reference TCA loader sends the same queries wrapped and unchained + // (`AA0A2208 80F24000024F0000`; Le='00' returns the listing directly). + // `fmt` is the run's command format; omitted = compact (the default the + // live card needs). + const query = '80F2' + p1 + p2 + '024F0000'; + return ramFormatIsExpanded(fmt) ? query : query + 'C0000000'; } // P2=02 expanded (E3) registry listing (GP Card Spec 11.4.2.3/11.4.3.1). diff --git a/frontend/sw.js b/frontend/sw.js index b7c7a95..6ec1617 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v352'; +const CACHE = 'simple-v353'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 1e21b42..af04e9d 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -23,6 +23,7 @@ function extractFunc(src, name) { // Extract chain builder functions and dependencies const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramCompatImportRows', 'ramCompatRowsHtml', 'ramCompatFailureHtml', 'ramCompatDetailHtml', 'lookupSw', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu', + 'ramFormatIsExpanded', 'ramFmtLadder', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', 'ramExpandedReport', 'ramExpandedMore', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate', @@ -375,6 +376,13 @@ test('ramGetStatusApdu builds the compact chain, never the expanded form', () => // GP 11.4.2.2: compact listings (P2.b2=0) carry the chained GET RESPONSE assert.strictEqual(ramGetStatusApdu('80', '00'), '80F28000024F0000C0000000'); assert.strictEqual(ramGetStatusApdu('20', '01'), '80F22001024F0000C0000000'); + // the expanded command format must not carry a GET RESPONSE (TS 102 226 + // 5.2.1.1): the reference TCA loader sends the same query wrapped unchained + // (`AA0A2208 80F24000024F0000`), Le='00' returns the listing directly + assert.strictEqual(ramGetStatusApdu('40', '00', 'expanded'), '80F24000024F0000'); + assert.strictEqual(ramGetStatusApdu('40', '01', 'expanded-ae'), '80F24001024F0000'); + assert.ok(!ramGetStatusApdu('40', '00', 'expanded').includes('C0000000'), + 'no GET RESPONSE in the expanded format'); // the expanded TLV form (P2=02/03) takes no GET RESPONSE and is not built: // its GET STATUS bytes would be `80F202 04 4F00 5C.. 00` assert.ok(!ramGetStatusApdu('20', '01').startsWith('80F2200204'), @@ -660,6 +668,51 @@ test('the explorer annotates a derived package version', () => { assert.ok(!plain.includes('from the app instance'), plain); }); +test('the expanded formats never chain a GET RESPONSE (TCA loader form)', () => { + assert.strictEqual(ramFormatIsExpanded('expanded'), true); + assert.strictEqual(ramFormatIsExpanded('expanded-ae'), true); + assert.strictEqual(ramFormatIsExpanded('compact'), false); + assert.strictEqual(ramFormatIsExpanded('auto'), false); + assert.strictEqual(ramFormatIsExpanded(undefined), false); + const src = extractFunc(html, 'ramExplore'); + // the listing pass follows the run's command format and pins it per send, + // so a chain can never ride inside an expanded packet + assert.ok(src.includes('ramFormatIsExpanded(sp.ram_format)'), 'the listing format must follow the run'); + assert.ok(src.includes('ramGetStatusApdu(p1, p2, listingFmt)'), 'the builder must follow the format'); + assert.ok(src.includes('ram_format: listingFmt'), 'the listing format must be pinned per send'); + // the registry pass: the chained retry is compact-only, the format ladder + // covers the expanded codings, and the working format is remembered + assert.ok(src.includes('!ramFormatIsExpanded(usedFmt)'), 'the chained retry must be compact-only'); + assert.ok(src.includes('ramFmtLadder(runFmt, expandedFmt)'), + 'the format ladder (with its remembered format) must drive the retries'); + assert.ok(src.includes('expandedFmt = fmt'), 'the working format must be remembered'); + assert.ok(src.includes('fmt: out.fmt'), 'the report must carry the serving format'); +}); + +test('ramFmtLadder orders the registry command formats cautiously', () => { + // the run's format first (null = no override), then the expanded codings, + // then compact as the last resort; the run's own format is not repeated + assert.deepStrictEqual(ramFmtLadder('compact', null), [null, 'expanded', 'expanded-ae']); + assert.deepStrictEqual(ramFmtLadder('expanded', null), [null, 'expanded-ae', 'compact']); + assert.deepStrictEqual(ramFmtLadder('expanded-ae', null), [null, 'expanded', 'compact']); + assert.deepStrictEqual(ramFmtLadder('auto', null), [null, 'expanded', 'expanded-ae', 'compact']); + // a format that already served data is tried first and never repeated + assert.deepStrictEqual(ramFmtLadder('compact', 'expanded'), ['expanded', 'expanded-ae']); + assert.deepStrictEqual(ramFmtLadder('expanded-ae', 'expanded-ae'), ['expanded-ae', 'expanded', 'compact']); +}); + +test('ramExpandedReport names the expanded serving format', () => { + globalThis.t = s => s; + const out = ramExpandedReport([ + { p1: '80', label: 'ISD', compact: false, form: 'lean', sw: '6310', entries: 3, fmt: 'expanded' }, + { p1: '40', label: 'Apps', compact: false, form: 'lean', sw: '9000', entries: 2, fmt: 'expanded-ae' }, + ]); + delete globalThis.t; + assert.ok(out.startsWith('Expanded registry:'), out); + assert.ok(out.includes('ISD [AA]'), out); + assert.ok(out.includes('Apps [AE 80]'), out); +}); + test('ramExpandedReport summarizes the query forms and the fallbacks', () => { globalThis.t = s => s; // every listing answered the reference form