diff --git a/README.md b/README.md index 7d6f3c3..816c86d 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ Builds SCP80 secured packets per ETSI TS 102 225. | Field | Size | Description | |---|---|---| | CPI | 1 | Command Packet Identifier (`02`) | -| CPL | 1 | Command Packet Length | +| CPL | 2 (SMS) | Command Packet Length (CHL..end incl. padding) | | CHI | 1 | Command Header Identifier (`01`) | | CHL | 1 | Command Header Length | | SPI | 2 | Security Parameter Indicator | @@ -424,7 +424,7 @@ Builds SCP80 secured packets per ETSI TS 102 225. | TAR | 3 | Toolkit Application Reference | | CNTR | 5 | Replay counter | | PCNTR | 1 | Padding counter | -| RC/CC/DS | 8 | Cryptographic Checksum / MAC | +| RC/CC/DS | 4–8 | Redundancy Check (CRC-32) / Cryptographic Checksum (MAC) / Digital Signature | | Secured Data | variable | Padded APDU (encrypted if required) | #### SPI1 (Security Level) diff --git a/README_RUS.md b/README_RUS.md index 402704c..8a387ce 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -390,7 +390,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со | Поле | Размер | Описание | |---|---|---| | CPI | 1 | Command Packet Identifier (`02`) | -| CPL | 1 | Command Packet Length | +| CPL | 2 (SMS) | Command Packet Length (CHL..конец, включая padding) | | CHI | 1 | Command Header Identifier (`01`) | | CHL | 1 | Command Header Length | | SPI | 2 | Security Parameter Indicator | @@ -399,7 +399,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со | TAR | 3 | Toolkit Application Reference | | CNTR | 5 | Счётчик повторов | | PCNTR | 1 | Padding counter | -| RC/CC/DS | 8 | Контрольная сумма / MAC | +| RC/CC/DS | 4–8 | CRC (CRC-32) / контрольная сумма (MAC) / цифровая подпись | | Secured Data | переменная | APDU (с шифрованием при необходимости) | #### SPI1 (Уровень безопасности) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index ead7bfa..b986146 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -285,7 +285,7 @@ ПолеРазмерОписание CPI1Command Packet Identifier (02) - CPL1Command Packet Length + CPL2 (SMS)Длина командного пакета (CHL..конец, включая padding) CHI1Command Header Identifier (01) CHL1Command Header Length SPI2Security Parameter Indicator @@ -294,7 +294,7 @@ TAR3Toolkit Application Reference CNTR5Счётчик повторов PCNTR1Padding counter - RC/CC/DS8Контрольная сумма / MAC + RC/CC/DS4–8CRC (CRC-32) / контрольная сумма (MAC) / цифровая подпись Secured Dataпер.APDU (с шифрованием при необходимости) @@ -304,10 +304,11 @@
  • AES-CBC шифрование (нулевой ICV, дополнение нулями до 16), ключи 16/24/32 байта (TS 102 225 §5.1.2, KIc x2)
  • Retail MAC (ISO 9797-1, MAC algorithm 3) для контрольной суммы DES/3DES
  • AES-CMAC (NIST SP 800-38B, усечённый до 8 октетов) для контрольной суммы AES (TS 102 225 §5.1.3.1, KID x2)
  • +
  • Redundancy Check (RC) — CRC-32 по тому же кадру заголовка, что и CC (TS 102 225 §5.1.3.2; ключ не нужен, SPI1 b2b1 = 01)
  • AES требует счётчик с защитой от повтора: биты SPI1 b5 b4 должны быть 10 (счётчик больше) или 11 (счётчик +1) согласно TS 102 225 §5.1.2/§5.1.3.1
  • Байт паддинга настраивается (00 по умолчанию или FF)
  • -

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

    +

    Кнопка Проверить в 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 увеличивает счётчик повторов и очищает пакет.

    3.2 RAM

    Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR предустановки, по умолчанию 000000).

    diff --git a/frontend/help.html b/frontend/help.html index 5ab229c..1760838 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -284,7 +284,7 @@ FieldSizeDescription CPI1Command Packet Identifier (02) - CPL1Command Packet Length + CPL2 (SMS)Command Packet Length (CHL..end incl. padding) CHI1Command Header Identifier (01) CHL1Command Header Length SPI2Security Parameter Indicator @@ -293,7 +293,7 @@ TAR3Toolkit Application Reference CNTR5Replay counter PCNTR1Padding counter - RC/CC/DS8Cryptographic Checksum / MAC + RC/CC/DS4–8Redundancy Check (CRC-32) / Cryptographic Checksum (MAC) / Digital Signature Secured Datavar.Padded APDU (encrypted if required) @@ -303,10 +303,11 @@
  • AES-CBC encryption (zero ICV, zero-padded to 16), 16/24/32-byte keys (TS 102 225 §5.1.2, KIc x2)
  • Retail MAC (ISO 9797-1 MAC algorithm 3) for the DES/3DES cryptographic checksum
  • AES-CMAC (NIST SP 800-38B, truncated to 8 octets) for the AES cryptographic checksum (TS 102 225 §5.1.3.1, KID x2)
  • +
  • Redundancy Check (RC) — CRC-32 over the same header frame as the CC (TS 102 225 §5.1.3.2; needs no key, SPI1 b2b1 = 01)
  • AES requires a replay-protected counter: SPI1 bits b5 b4 must be 10 (counter higher) or 11 (counter +1) per TS 102 225 §5.1.2/§5.1.3.1
  • Padding byte configurable (00 default, or FF)
  • -

    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). 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 and clears the packet.

    +

    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 and clears the packet.

    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).

    diff --git a/frontend/index.html b/frontend/index.html index 4fbf8e2..b1d4e1e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1559,7 +1559,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.5.0'; +const SIMPLE_VERSION = '3.5.1'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -4500,10 +4500,23 @@ function aesCmac(data, keyBytes) { return mac.subarray(0, 8); } +// Redundancy Check (RC) = CRC-32 (TS 102 225 5.1.3.2): reflected polynomial +// 0xEDB88320, init and final XOR 0xFFFFFFFF, result big-endian - byte-identical +// to pySim's zlib.crc32() usage. +function crc32Bytes(data) { + let crc = 0xFFFFFFFF; + for (let i = 0; i < data.length; i++) { + crc ^= data[i]; + for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xEDB88320 & -(crc & 1)); + } + crc = (crc ^ 0xFFFFFFFF) >>> 0; + return new Uint8Array([(crc >>> 24) & 0xFF, (crc >>> 16) & 0xFF, (crc >>> 8) & 0xFF, crc & 0xFF]); +} + // ===== Secure Packet assembly ===== // TS 102 225 Table 1/2 + TS 31.115 Table 1 (SMS-PP): // CPL(2) CHL(1) SPI(2) KIc(1) KID(1) TAR(3) CNTR(5) PCNTR(1) [RC/CC/DS] secured-data(+padding) -// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig. +// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig (CC 8, RC 4). // CPI (0x70) and CHI (null) are NOT part of the SMS packet data. function genSp() { _genSpBuild(); @@ -4575,7 +4588,9 @@ function _genSpBuild() { const kidIsAes = kidAlg === 0x02; const ciphering = (spi1 & 0x04) !== 0; - const hasMac = (spi1 & 0x03) === 0x02; + const rcCcDs = spi1 & 0x03; // 00 none / 01 RC / 10 CC / 11 DS (not offered) + const hasRc = rcCcDs === 0x01; + const hasMac = rcCcDs === 0x02; const resultEl = document.getElementById('sp-result'); @@ -4624,13 +4639,13 @@ function _genSpBuild() { const apdu = hexToBytes(apduHex); - const macLen = hasMac ? 8 : 0; + const sigLen = hasMac ? 8 : (hasRc ? 4 : 0); // padding aligns CNTR+PCNTR+RC/CC/DS+data to the cipher block size (ciphering only) const cipherBlock = kicIsAes ? 16 : 8; - const padCnt = ciphering ? (cipherBlock - ((6 + macLen + apdu.length) % cipherBlock)) % cipherBlock : 0; + const padCnt = ciphering ? (cipherBlock - ((6 + sigLen + apdu.length) % cipherBlock)) % cipherBlock : 0; - const chl = 13 + macLen; - const cpl = 14 + macLen + apdu.length + padCnt; + const chl = 13 + sigLen; + const cpl = 14 + sigLen + apdu.length + padCnt; const packetLen = 3 + chl + apdu.length + padCnt; const packet = new Uint8Array(packetLen); @@ -4652,18 +4667,18 @@ function _genSpBuild() { packet[14] = parseInt(cntrHex.substr(8,2), 16); packet[15] = padCnt; const macOff = 16; - const dataOff = macOff + macLen; + const dataOff = macOff + sigLen; packet.set(apdu, dataOff); for (let i = 0; i < padCnt; i++) packet[dataOff + apdu.length + i] = padByte; - if (hasMac && kidKey) { - // CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding), + if (sigLen) { + // RC/CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding), // i.e. everything up to (but excluding) the RC/CC/DS field (TS 31.115 4.2). - const macInput = new Uint8Array(packet.length - macLen); - macInput.set(packet.subarray(0, macOff)); - macInput.set(packet.subarray(dataOff), macOff); - const mac = kidIsAes ? aesCmac(macInput, kidKey) : cbcMac(macInput, kidKey); - packet.set(mac, macOff); + const sigInput = new Uint8Array(packet.length - sigLen); + sigInput.set(packet.subarray(0, macOff)); + sigInput.set(packet.subarray(dataOff), macOff); + if (hasRc) packet.set(crc32Bytes(sigInput), macOff); + else if (kidKey) packet.set(kidIsAes ? aesCmac(sigInput, kidKey) : cbcMac(sigInput, kidKey), macOff); } if (ciphering && kicKey) { @@ -4675,9 +4690,12 @@ function _genSpBuild() { packet.set(encrypted, 10); } - // pySim encode_cmd parity: the 2-byte CPL prefix is transmitted only when - // ciphering is applied; unciphered packets start at CHL (v1.9.5). - resultEl.value = bytesToHex(ciphering ? packet : packet.subarray(2)); + // CPL transmission (TS 31.115 Table 1 NOTE + 4.3): it is transmitted when + // the packet is ciphered or carries RC/CC/DS - it is part of their input - + // and it is required once the packet needs SMS concatenation. A single + // unprotected SM keeps pySim's CHL-first form ("not absolutely necessary"). + const withCpl = ciphering || rcCcDs !== 0 || (packet.length - 2) > SCP80_SINGLE_BYTES; + resultEl.value = bytesToHex(withCpl ? packet : packet.subarray(2)); } // ===== BER-TLV ===== @@ -13017,13 +13035,17 @@ function fcpFileDescriptor(v) { function fcpLifeCycle(v) { if (v.length !== 2) return v; const b = parseInt(v, 16); + // TS 102 221 Table 11.7b: the state rows all keep b8-b5 clear; b8 set is + // proprietary, any other unlisted value is RFU. + if (b & 0x80) return 'proprietary (' + v + ')'; + if (b & 0x70) return 'RFU (' + v + ')'; if (b === 0x00) return 'no information'; if (b === 0x01) return 'creation'; if (b === 0x03) return 'initialization'; if ((b & 0x0C) === 0x0C) return 'termination'; if ((b & 0x05) === 0x05) return 'operational, activated'; if ((b & 0x05) === 0x04) return 'operational, deactivated'; - return 'proprietary (' + v + ')'; + return 'RFU (' + v + ')'; } // TS 102 221 11.1.1.4.8: SFI in b8-b4, b3-b1 zero; length 0 = no short id. @@ -13033,6 +13055,35 @@ function fcpSfi(v) { return v; } +// TS 102 221 11.1.1.4.10 / 9.5.2: PS template DO 'C6' = PS_DO ('90') + optional +// usage qualifier ('95') + key reference ('83', repeatable). The PS_DO is a +// bitmap over the key references in order, MSB first; a set bit = PIN enabled. +function fcpPsTemplate(v) { + const parsed = fcpParseTlvs(v); + let psDo = null, uq = null; + const pins = []; + for (const t of parsed.tlvs) { + if (t.tag === '90') psDo = t.value; + else if (t.tag === '95') uq = t.value.length ? parseInt(t.value, 16) : 0; + else if (t.tag === '83' && t.value.length) { + const idx = pins.length; + let enabled = false; + if (psDo && psDo.length) { + const byteIdx = idx >> 3, bit = 7 - (idx & 7); + if (byteIdx * 2 + 2 <= psDo.length) enabled = ((parseInt(psDo.substr(byteIdx * 2, 2), 16) >> bit) & 1) === 1; + } + let s = pinKeyRefName(parseInt(t.value, 16)) + ': ' + (enabled ? 'enabled' : 'disabled'); + if (uq === null) s += ', always verified'; + else if (uq === 0) s += ', verification not used'; + else if (uq & 0x08) s += ', verify'; + else s += ', usage 0x' + uq.toString(16).toUpperCase().padStart(2, '0'); + pins.push(s); + uq = null; + } + } + return pins.length ? pins.join('; ') : (v || ''); +} + function fcpDo(key, value) { if (/^[0-9A-F]+$/.test(value) === false) value = value || ''; switch (key) { @@ -13053,7 +13104,7 @@ function fcpDo(key, value) { case 'A0': return { name: 'Security attributes (data objects)', decoded: value }; case 'A5': return { name: 'Proprietary information', decoded: null }; case 'AB': return { name: 'Security attributes (expanded)', decoded: value }; - case 'C6': return { name: 'PIN status template DO', decoded: null }; + case 'C6': return { name: 'PIN status template DO', decoded: fcpPsTemplate(value) }; case 'A5/80': { const b = fcpInt(value); const parts = [(b & 0x01) ? 'clock stop allowed' : 'clock stop not allowed']; @@ -13910,10 +13961,100 @@ function efDecDir(b) { return apps.length ? { applications: apps } : { raw: efHex(b) }; } +// TS 102 221 9.4.1 Table 9.3: PIN key reference names (ordinal + local PINs). +function pinKeyRefName(v) { + const names = { + 0x01: 'PIN app1', 0x02: 'PIN app2', 0x03: 'PIN app3', 0x04: 'PIN app4', + 0x05: 'PIN app5', 0x06: 'PIN app6', 0x07: 'PIN app7', 0x08: 'PIN app8', + 0x0A: 'ADM1', 0x0B: 'ADM2', 0x0C: 'ADM3', 0x0D: 'ADM4', 0x0E: 'ADM5', + 0x11: 'universal PIN', + 0x81: 'PIN2 app1', 0x82: 'PIN2 app2', 0x83: 'PIN2 app3', 0x84: 'PIN2 app4', + 0x85: 'PIN2 app5', 0x86: 'PIN2 app6', 0x87: 'PIN2 app7', 0x88: 'PIN2 app8', + 0x8A: 'ADM6', 0x8B: 'ADM7', 0x8C: 'ADM8', 0x8D: 'ADM9', 0x8E: 'ADM10' + }; + if (names[v]) return names[v]; + return 'key ref 0x' + v.toString(16).toUpperCase().padStart(2, '0'); +} + +// ISO 7816-4 Tables 16/17: AM byte bit meanings. A record does not store +// whether it protects an EF or a DF, so both mappings are shown. +function efArrAmLabel(am) { + const bits = [ + [0x40, 'DELETE (self)'], + [0x20, 'TERMINATE EF / TERMINATE DF'], + [0x10, 'ACTIVATE'], + [0x08, 'DEACTIVATE'], + [0x04, 'WRITE/APPEND (EF) / CREATE FILE DF (DF)'], + [0x02, 'UPDATE/ERASE (EF) / CREATE FILE EF (DF)'], + [0x01, 'READ/SEARCH (EF) / DELETE FILE child (DF)'], + ]; + if (am & 0x80) { + const low = bits.filter(b => (b[0] & 0x07) && (am & b[0])).map(b => b[1]); + return 'bits 7-4 proprietary' + (low.length ? ' + ' + low.join(' + ') : ''); + } + const ops = bits.filter(b => am & b[0]).map(b => b[1]); + return ops.length ? ops.join(' + ') : 'no operations'; +} + +// One SC_DO (ISO 7816-4 Table 23; TS 102 221 Annex E.3). +function efArrSc(tlv) { + const tag = tlv.tag, v = tlv.value; + if (tag === 0x90) return 'always'; + if (tag === 0x97) return 'never'; + if (tag === 0x9E) return 'security condition 0x' + efHex(v); + if (tag === 0xA4) { + let ref = null, uq = null; + for (const t of efTlv(v)) { + if (t.tag === 0x83 && t.value.length) ref = t.value[0]; + else if (t.tag === 0x95 && t.value.length) uq = t.value[0]; + } + let s = ref === null ? 'PIN' : pinKeyRefName(ref); + if (uq === null) s += ' (verify)'; + else if (uq === 0x00) s += ' (verification not used)'; + else if (uq & 0x08) s += ' (verify)'; + else s += ' (usage 0x' + uq.toString(16).toUpperCase().padStart(2, '0') + ')'; + return s; + } + if (tag === 0xB4 || tag === 0xB6 || tag === 0xB8) return 'SM CRT 0x' + tag.toString(16).toUpperCase(); + if (tag === 0xA0) return '(' + efTlv(v).map(x => efArrSc(x)).join(' OR ') + ')'; + if (tag === 0xAF) return '(' + efTlv(v).map(x => efArrSc(x)).join(' AND ') + ')'; + if (tag === 0xA7) { + const inner = efTlv(v)[0]; + return 'NOT ' + (inner ? efArrSc(inner) : '?'); + } + return '0x' + tag.toString(16).toUpperCase().padStart(2, '0') + ' ' + efHex(v); +} + +// One AM_DO (ISO 7816-4 Table 21; INCREASE/RESIZE per TS 102 221 11.1.8.1 and +// TS 102 222 6.10). Returns null for SC_DOs. +function efArrAmDo(tlv) { + const tag = tlv.tag, v = tlv.value; + if (tag === 0x80 && v.length) return 'AM 0x' + efHex(v) + ' (' + efArrAmLabel(v[0]) + ')'; + if (tag === 0x84) { + const ins = v.length ? v[v.length - 1] : 0; + if (ins === 0x32) return 'INCREASE (AM_DO 0x84, INS 0x32)'; + if (ins === 0xD4) return 'RESIZE FILE (AM_DO 0x84, INS 0xD4)'; + return 'AM_DO 0x84 (INS 0x' + ins.toString(16).toUpperCase().padStart(2, '0') + ')'; + } + if (tag >= 0x81 && tag <= 0x8F) return 'command description 0x' + tag.toString(16).toUpperCase() + ' (' + efHex(v) + ')'; + if (tag === 0x9C) return 'proprietary state machine (' + efHex(v) + ')'; + return null; +} + +// EF_ARR records: AM_DO + SC_DO sequences in the expanded format +// (ISO 7816-4 5.4.3.2; TS 102 221 9.2.7/13.4). SC_DOs after one AM_DO +// without an OR template all have to be fulfilled (AND). function efDecArr(b) { const rules = []; - for (const t of efTlv(b)) rules.push({ tag: '0x' + t.tag.toString(16).toUpperCase().padStart(2, '0'), hex: efHex(t.value) }); - return rules.length ? { rules: rules } : { raw: efHex(b) }; + for (const t of efTlv(b)) { + const am = efArrAmDo(t); + if (am !== null) { rules.push({ am: am, scs: [] }); continue; } + const sc = efArrSc(t); + if (!rules.length) rules.push({ am: '(no AM_DO)', scs: [] }); + rules[rules.length - 1].scs.push(sc); + } + if (!rules.length) return { raw: efHex(b) }; + return { rules: rules.map(r => r.am + (r.scs.length ? ': ' + r.scs.join(' AND ') : '')) }; } function efDecPnn(b) { diff --git a/frontend/sw.js b/frontend/sw.js index c8be22e..7421825 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v252'; +const CACHE = 'simple-v253'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js index c527a43..bac7571 100644 --- a/frontend/tests/ef_decode.test.js +++ b/frontend/tests/ef_decode.test.js @@ -28,6 +28,7 @@ const FNS = [ 'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc', 'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl', 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn', + 'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', 'efDecExt1', 'efDecSms', 'efDecSume', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes', 'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten', 'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml', @@ -152,8 +153,22 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => { assert.strictEqual(dir.applications.length, 1); assert.strictEqual(dir.applications[0].aid, 'A0000000871002FFFFFFFF8907090000'); assert.strictEqual(dir.applications[0].label, 'USim1'); + // EF.ARR: AM_DO 01 (read) guarded by ADM1 (TS 102 221 9.2.7 / ISO 7816-4 5.4.3.2) const arr = efDecArr(efBytes('800101a40683010a950108')); - assert.deepStrictEqual(arr.rules, [{ tag: '0x80', hex: '01' }, { tag: '0xA4', hex: '83010A950108' }]); + assert.deepStrictEqual(arr.rules, ['AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)']); + // Two rules: update needs PIN app1 or app2, read is always + assert.deepStrictEqual( + efDecArr(efBytes('800102a010a406830101950108a4068301029501088001019000')).rules, + ['AM 0x02 (UPDATE/ERASE (EF) / CREATE FILE EF (DF)): (PIN app1 (verify) OR PIN app2 (verify))', + 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always']); + // INCREASE / RESIZE ride AM_DO 0x84 with the command INS as value + assert.deepStrictEqual(efDecArr(efBytes('8401329000')).rules, + ['INCREASE (AM_DO 0x84, INS 0x32): always']); + assert.deepStrictEqual(efDecArr(efBytes('8401D49000')).rules, + ['RESIZE FILE (AM_DO 0x84, INS 0xD4): always']); + // b8=1: bits 7-4 are proprietary + assert.deepStrictEqual(efDecArr(efBytes('8001819000')).rules, + ['AM 0x81 (bits 7-4 proprietary + READ/SEARCH (EF) / DELETE FILE child (DF)): always']); const impi = efDecNai(efBytes('803137333830303630303030303031303140696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')); assert.strictEqual(impi.text, '738006000000101@ims.mnc000.mcc738.3gppnetwork.org'); const smsp = efDecSmsp(efBytes('534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9')); diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index f095044..c5acd16 100644 --- a/frontend/tests/profiler.test.js +++ b/frontend/tests/profiler.test.js @@ -21,7 +21,8 @@ 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', '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']; +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', + 'pinKeyRefName', 'fcpPsTemplate', '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'; @@ -694,6 +695,21 @@ test('fcpDecode maps life cycle status per table 11.7b', () => { assert.strictEqual(lcs('0C'), 'termination'); assert.strictEqual(lcs('0D'), 'termination'); assert.strictEqual(lcs('80'), 'proprietary (80)'); + assert.strictEqual(lcs('8C'), 'proprietary (8C)'); + // Table 11.7b: unlisted values with b8 clear are RFU, not proprietary + assert.strictEqual(lcs('02'), 'RFU (02)'); + assert.strictEqual(lcs('2C'), 'RFU (2C)'); +}); + +test('fcpDecode decodes the PS template DO (C6) with PIN key references', () => { + // TS 102 221 11.1.1.4.10 / 9.5.2: PS_DO bitmap over the following key refs + const dec = hex => fcpDecode('620BC609' + hex).items.find(it => it.key === 'C6').decoded; + assert.strictEqual(dec('9001C0950108830101'), 'PIN app1: enabled, verify'); + assert.strictEqual(fcpPsTemplate('9001C095010883010183010A'), + 'PIN app1: enabled, verify; ADM1: enabled, always verified'); + assert.strictEqual(fcpPsTemplate('90014083010183010A'), + 'PIN app1: disabled, always verified; ADM1: enabled, always verified'); + assert.strictEqual(fcpPsTemplate('900100830111'), 'universal PIN: disabled, always verified'); }); test('fcpDecode decodes A5 proprietary sub-TLVs', () => { diff --git a/frontend/tests/sp.test.js b/frontend/tests/sp.test.js index 2cb995c..4f4651f 100644 --- a/frontend/tests/sp.test.js +++ b/frontend/tests/sp.test.js @@ -27,7 +27,7 @@ function extractFunc(src, name) { } const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt', - 'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys', + 'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys', 'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText', 'spShowSizeInfo']; let code = ''; @@ -95,16 +95,35 @@ test('ciphered + CC SPI 16/01 (counter_must_be_higher, plaintext PoR)', () => { '00201516011515B00000E42573469E68A8462A57A505B0E2B1C09C1928C7A182311F'); }); -test('unciphered + CC SPI 02/09', () => { - assert.strictEqual( - makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' }), - '1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00'); +test('unciphered + CC SPI 02/09 carries the CPL', () => { + const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' }); + assert.strictEqual(out, + '001D1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00'); + assert.strictEqual(parseInt(out.slice(0, 4), 16), out.length / 2 - 2); }); -test('unciphered packet starts at CHL, no CPL prefix (pySim parity)', () => { - const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' }); - assert.strictEqual(out.slice(0, 2), '15'); - assert.strictEqual(out.length, 58); +test('unprotected packet (SPI 00) keeps the pySim CHL-first form', () => { + assert.strictEqual( + makeRun({ 'sp-spi1': '00', 'sp-spi2-hex': '09' }), + '0D00091515B0000000000000010000A40000023F00'); +}); + +test('unprotected concatenated packet gains the CPL (TS 31.115 4.3)', () => { + const out = makeRun({ 'sp-spi1': '00', 'sp-spi2-hex': '09', 'sp-apdu': 'A0'.repeat(200) }); + assert.strictEqual(out.length / 2, 216); + assert.strictEqual(parseInt(out.slice(0, 4), 16), 214); + assert.strictEqual(out.slice(4, 6), '0D'); + assert.strictEqual(scp80SegmentInfo(out).segments, 2); +}); + +test('RC (SPI 01) computes CRC-32 over the CPL frame', () => { + assert.strictEqual( + makeRun({ 'sp-spi1': '01', 'sp-spi2-hex': '09' }), + '00191101091515B0000000000000010050C942DC00A40000023F00'); +}); + +test('crc32Bytes known answer (TS 102 225 Annex B)', () => { + assert.strictEqual(bytesToHex(crc32Bytes(hexToBytes('0102030405'))), '470B99F4'); }); test('sysmocom public reference vector (spi1 04 / spi2 19, cntr=0)', () => { @@ -174,7 +193,7 @@ test('AES unciphered + CC SPI 12/09 (counter higher)', () => { 'sp-kic-key': KIC_AES, 'sp-kid-key': KID_AES, }), - '1512092222B0001100000000110029826122C7A0B79500A40004023F00'); + '001D1512092222B0001100000000110029826122C7A0B79500A40004023F00'); }); test('AES ciphered + CC SPI 1E/19 (counter +1)', () => { diff --git a/pyproject.toml b/pyproject.toml index 2fcddb5..5d941df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.5.0" +version = "3.5.1" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 25d1eb5..8681e56 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -29,7 +29,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.5.0' +VERSION = '3.5.1' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -937,9 +937,12 @@ def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_he otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex) spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16)) out = OtaDialectSms().encode_cmd(otak, h2b(tar_hex), spi, h2b(apdu_hex)) - if not spi['ciphering'] and spi['rc_cc_ds'] != 'no_rc_cc_ds': + if not spi['ciphering'] and (spi['rc_cc_ds'] != 'no_rc_cc_ds' + or len(out) > SCP80_SINGLE_BYTES): # pySim drops the CPL octets from its unciphered output; re-add them - # (they are included in the RC/CC/DS calculation) per TS 31.115 4.2. + # per TS 31.115 4.2 (they are part of the RC/CC/DS input) and per + # Table 1 NOTE / 4.3 (required for concatenation - the CHL-to-end + # range exceeds one SM). # CPL counts octets from the CHL octet to the last octet of the # Secured Data (incl. padding); pySim's unciphered output is exactly # that range, so the CPL value equals its length. @@ -1031,10 +1034,12 @@ def _build_secured_packet(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex) spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16)) out = _encode_cmd_unlimited(otak, spi, h2b(tar_hex), h2b(apdu_hex)) - if not spi['ciphering'] and spi['rc_cc_ds'] != 'no_rc_cc_ds': + if not spi['ciphering'] and (spi['rc_cc_ds'] != 'no_rc_cc_ds' + or len(out) > SCP80_SINGLE_BYTES): # CPL counts octets from the CHL octet to the last octet of the # Secured Data (incl. padding) - exactly the length of the - # unciphered range. + # unciphered range. Added for the RC/CC/DS input and for + # concatenation (TS 31.115 Table 1 NOTE / 4.3). cpl = len(out) out = cpl.to_bytes(2, 'big') + out return b2h(out), spi diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 9eb6099..e97d1c5 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -59,6 +59,8 @@ REFERENCE_VECTORS = { '00201516011515b00000e42573469e68a8462a57a505b0e2b1c09c1928c7a182311f', ('02', '09'): '001d1502091515b0000000000000010085a8ca1a9828b0bb00a40000023f00', + ('01', '09'): + '00191101091515b0000000000000010050c942dc00a40000023f00', } # AES-128 reference vectors (public synthetic keys from pySim test_ota.py). @@ -153,6 +155,10 @@ class TestOtaReference(unittest.TestCase): out, _ = _ota_reference('02', '09', '15', '15', 'b00000', '0000000001', APDU, K, K) self.assertEqual(out, REFERENCE_VECTORS[('02', '09')]) + def test_unciphered_rc_reference(self): + out, _ = _ota_reference('01', '09', '15', '15', 'b00000', '0000000001', APDU, K, K) + self.assertEqual(out, REFERENCE_VECTORS[('01', '09')]) + def test_unciphered_cpl_is_0x001d(self): # Regression: CPL counts octets from the CHL octet to the last octet # of the secured data (29 here), it must NOT be len(out)-2 (27/0x001b). @@ -229,6 +235,22 @@ class TestSmsConcatenation(unittest.TestCase): self.assertEqual(_split_secured_packet(b'A' * 140, include_cpi=False), [b'A' * 140]) + def test_unprotected_single_sm_keeps_the_chl_first_form(self): + # TS 31.115 Table 1 NOTE: the CPL is "not absolutely necessary" in a + # single SM - an unprotected packet keeps pySim's CHL-first form. + out, _ = _build_secured_packet('00', '09', '15', '15', 'b00000', + '0000000001', APDU, K, K) + self.assertEqual(out[:2], '0d') + + def test_unprotected_concatenated_packet_gains_the_cpl(self): + # ... but it is required once the packet needs concatenation + # (TS 31.115 Table 1 NOTE / 4.3). + out, _ = _build_secured_packet('00', '09', '15', '15', 'b00000', + '0000000001', 'A0' * 200, K, K) + self.assertEqual(len(out) // 2, 216) + self.assertEqual(out[:4], '00d6') # CPL = 214 = CHL..end + self.assertEqual(int(out[:4], 16), len(out) // 2 - 2) + def test_240_byte_load_block_encodes_and_fits_the_card_buffer(self): # The RAM path no longer clamps LOAD blocks to one SMS: a 240-byte # block (the GP maximum) becomes a concatenated command.