Files
simple/frontend/tests/ts102226.test.js
T
catarrh 9fb2ad5d2b fix: send the STK install parameters as entered (v3.6.9)
The RAM install form sent the `STK parameters (hex)` field, which was only
regenerated when the toolkit checkbox or the mode select changed - editing
the TAR (or any other toolkit field) afterwards was silently dropped.  The
live install therefore carried the form defaults (TAR B00001, textLen 0,
menus 0, MSL 16, channels 0) instead of the entered AF4D01 / MSL 12 /
channels 1, and the card rejected the install parameters with 6A80
(TS 102 226 8.2.1.3.2.7: a TAR already assigned on the card).

- every `rc-tk-*` field regenerates the hex on input/change; the install
  recomputes it at send time unless the user hand-edited the hex
  (`dataset.manual`), so the edit order can no longer drop values;
- one pure `stkParamsBuild` serves the RAM form (`buildRcToolkitParams`) and
  the RAM/GP chain rows (`buildTkParams`) - no drift between them (the chain
  row also gains the menu-ID <= 7F check);
- applet TAR field: empty by default, no placeholder - B0 00 01 is the
  allocated ADF RFM TAR (TS 101 220 Annex D), not an applet TAR; an empty
  field is coded as TAR length 00 (the card may take a TAR from the AID only
  when the AID carries one, PIX hex digits 15-20);
- the chain builder's INSTALL/LOAD rows drop the trailing Le (the v3.6.8
  server form; a trailing Le made the card execute a phantom command);
- tests: stk_params.test.js (the decrypted live parameters, empty-TAR
  coding, CA wrapper, long-form lengths, rejection cases, form wiring, and
  the form -> hex path with the real builders) and ram_counter_flow.test.js
  (the Explore delete persists the consumed counter through the real
  ramSaveCntr); STK fixtures no longer use B00001.

631 frontend / 496 Python green; version 3.6.9; sw simple-v282.
2026-09-28 01:49:35 +03:00

318 lines
16 KiB
JavaScript

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);
}
// Command builders and the expanded-script wiring they feed.
// Reference vectors are computed from the pinned specs:
// TS 102 221 11.1.7/11.1.8 (SEARCH RECORD, INCREASE), 11.3 (SET/RETRIEVE DATA)
// TS 102 222 tables 2/7/16/17 (CREATE/DELETE/RESIZE FILE, FCP templates)
// TS 151 011 9.2.7/9.2.8 (SEEK, INCREASE for 2G SIM)
// TS 102 226 9.2.1-9.2.4 (PUSH), 5.2.1 (Command TLVs)
// GlobalPlatform Card Spec 11.8 (PUT KEY)
const FNS = ['berLenStr', 'buildApdu', 'buildSelect', 'escHtml', 'esc', 'chainInit',
'chainKind', 'chainIsEmbedded', 'chainCommands', 'chainBuildRowHex',
'chainSimBuildRowHex', 'chainRamBuildRowHex', 'stkParamsBuild', 'chainPushData', '_hotaAsciiHex',
'chainApduList', 'chainBuildFcp', 'pushSectionApdus', 'pushOpenChannelTlvs'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['CHAIN_CMDS_SIM', 'CHAIN_CMDS_USIM', 'CHAIN_CMDS_RAM']) {
const m = html.match(new RegExp('const ' + c + ' = \\[[\\s\\S]*?\\n\\];'));
if (!m) throw new Error('catalog not found: ' + c);
code += m[0].replace('const ', 'var ') + '\n';
}
code += html.match(/const _chains = \{\};/)[0].replace('const ', 'var ') + '\n';
code += html.match(/const CHAIN_LISTENERS = \{\};/)[0].replace('const ', 'var ') + '\n';
// The real chain renderers are DOM-bound; the pure builders under test only
// need chainRender as a no-op, and chainApduList's splitter is exercised with
// a stub (the splitter itself is covered by apdu_parse.test.js).
code += 'function chainRender() {}\n';
code += 'var __split = [];\nfunction parseCompactApdus() { return __split; }\n';
eval(code);
function row(cmd, fields) {
return { cmd: cmd, fields: fields || {} };
}
test('the command catalogs cover the TS 102 226 table 7.1/8.1/9.1 gaps', () => {
const sim = CHAIN_CMDS_SIM.map(c => c.value);
const usim = CHAIN_CMDS_USIM.map(c => c.value);
const ram = CHAIN_CMDS_RAM.map(c => c.value);
['search-record', 'increase'].forEach(c => assert.ok(sim.includes(c), 'SIM ' + c));
['search-record', 'increase', 'create-file', 'delete-file', 'resize-file', 'set-data', 'retrieve-data']
.forEach(c => assert.ok(usim.includes(c), 'USIM ' + c));
['put-key', 'push'].forEach(c => assert.ok(ram.includes(c), 'RAM ' + c));
});
test('chainKind maps the stock and embedded chains; embedded ones hide GET RESPONSE', () => {
assert.strictEqual(chainKind('chain-sim'), 'sim');
assert.strictEqual(chainKind('chain-usim'), 'usim');
assert.strictEqual(chainKind('chain-ram'), 'ram');
assert.strictEqual(chainKind('ber-sim-7'), 'sim');
assert.strictEqual(chainKind('ber-usim-12'), 'usim');
assert.strictEqual(chainKind('ber-ram-1'), 'ram');
assert.ok(chainIsEmbedded('ber-sim-3'));
assert.ok(!chainIsEmbedded('chain-sim'));
// GET RESPONSE is not used in the expanded format (TS 102 226 5.2.1.1).
assert.ok(chainCommands('chain-sim').some(c => c.value === 'get-response'));
assert.ok(!chainCommands('ber-sim-3').some(c => c.value === 'get-response'));
assert.ok(!chainCommands('ber-ram-3').some(c => c.value === 'get-response'));
});
test('USIM SEARCH RECORD: simple and enhanced (TS 102 221 11.1.7)', () => {
_chains['chain-usim'] = { rows: [] };
// Simple forward from record 2 (P2=10), pattern ABCD, Le=00.
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('search-record', { srMode: '10', srRec: '02', srPattern: 'ABCD', le: '00' })),
'00A2021002ABCD00');
// Enhanced (P2=18): 2-byte indication + search string.
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('search-record', { srMode: '18', srRec: '00', srInd: '1800', srPattern: 'AB', le: '00' })),
'00A20018031800AB00');
// Backward from record 1 (P2=12).
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('search-record', { srMode: '12', srRec: '01', srPattern: 'AA', le: '00' })),
'00A2011201AA00');
// Missing pattern: no APDU.
assert.strictEqual(chainSimBuildRowHex('chain-usim', 0, row('search-record', { srPattern: '' })), '');
});
test('SIM SEEK: type/mode in P2, pattern in the data field (TS 151 011 9.2.7)', () => {
_chains['chain-sim'] = { rows: [] };
// Type 1, from next forward.
assert.strictEqual(
chainSimBuildRowHex('chain-sim', 0, row('search-record', { srMode: '02', srPattern: '112233', le: '' })),
'A0A2000203112233');
// Type 2, from the beginning: Le appended.
assert.strictEqual(
chainSimBuildRowHex('chain-sim', 0, row('search-record', { srMode: '10', srPattern: '11', le: '00' })),
'A0A20010011100');
});
test('INCREASE: TS 102 221 (USIM, optional SFI) and TS 51 011 (SIM)', () => {
_chains['chain-sim'] = { rows: [] };
_chains['chain-usim'] = { rows: [] };
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('increase', { value: '000001', le: '00' })),
'003200000300000100');
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('increase', { sfi: '05', value: '000001', le: '00' })),
'003285000300000100');
// TS 51 011 9.2.8: class A0, P1/P2 = 00, P3 = 03.
assert.strictEqual(
chainSimBuildRowHex('chain-sim', 0, row('increase', { value: '000001' })),
'A032000003000001');
assert.strictEqual(chainSimBuildRowHex('chain-usim', 0, row('increase', { value: '' })), '');
});
test('CREATE/DELETE/RESIZE FILE (TS 102 222 tables 2, 7, 16)', () => {
_chains['chain-usim'] = { rows: [] };
const fcp = '62138202002183026F078A01058C027F0080020010';
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('create-file', { fcp: fcp })),
'00E0000015' + fcp);
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('create-file', { fcp: '83026F07' })),
'');
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('delete-file', { fid: '6F07' })),
'00E40000026F07');
// RESIZE FILE uses CLA 80 (TS 102 222 table 1: '8X'/'CX').
const rfcp = '620883026F0780020010';
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('resize-file', { mode: '00', fcp: rfcp })),
'80D400000A' + rfcp);
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('resize-file', { mode: '01', fcp: rfcp })),
'80D401000A' + rfcp);
});
test('SET DATA / RETRIEVE DATA block coding (TS 102 221 table 11.35)', () => {
_chains['chain-usim'] = { rows: [] };
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('set-data', { sdMode: '80', sfi: '00', value: '80017F' })),
'00DB00800380017F');
// Next block with SFI 5.
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('set-data', { sdMode: '00', sfi: '05', value: '80017F' })),
'00DB00050380017F');
// First block, tag 5C, Le 00.
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('retrieve-data', { sdMode: '80', sfi: '00', tag: '5C', le: '00' })),
'00CB0080015C00');
// No tag: case 2.
assert.strictEqual(
chainSimBuildRowHex('chain-usim', 0, row('retrieve-data', { sdMode: '80', sfi: '00', tag: '', le: '00' })),
'00CB008000');
});
test('PUT KEY (GlobalPlatform Card Spec 11.8)', () => {
_chains['chain-ram'] = { rows: [] };
assert.strictEqual(
chainRamBuildRowHex(0, row('put-key', { keyVer: '01', keyId: '02', data: '1122334455667788' })),
'80D00102081122334455667788');
assert.strictEqual(chainRamBuildRowHex(0, row('put-key', { data: '' })), '');
});
test('PUSH: BIP channel opening and identification packet (TS 102 226 9.2.1/9.2.4)', () => {
_chains['chain-ram'] = { rows: [] };
// No TLVs: the application uses its defaults, so the APDU is case 1.
assert.strictEqual(chainRamBuildRowHex(0, row('push', { pushType: '01', pushData: '' })), '80EC0101');
assert.strictEqual(
chainRamBuildRowHex(0, row('push', { pushType: '01', pushData: '350103' })),
'80EC010103350103');
assert.strictEqual(
chainRamBuildRowHex(0, row('push', { pushType: '04', pushIdent: '0102' })),
'80EC01040436020102');
assert.strictEqual(chainRamBuildRowHex(0, row('push', { pushType: '04', pushIdent: '' })), '80EC0104');
});
test('PUSH: CAT_TP link and TCP connection data fields (TS 102 226 9.2.2/9.2.3)', () => {
// CAT_TP: transport level 3C (protocol type 00) + optional 39/36.
assert.strictEqual(chainPushData({ pushType: '02', pushPort: '1F90' }), '3C03001F90');
assert.strictEqual(
chainPushData({ pushType: '02', pushPort: '1F90', pushSdu: '0200', pushIdent: 'ABCD' }),
'3C03001F90390202003602ABCD');
assert.strictEqual(chainPushData({ pushType: '02', pushPort: '' }), '');
// TCP: bearer 35, transport 3C (protocol type 02), destination 3E, NAA 47.
assert.strictEqual(
chainPushData({ pushType: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet' }),
'3501033C030200503E05210A0000014708696E7465726E6574');
assert.strictEqual(
chainPushData({ pushType: '03', pushPort: '0050', pushBearer: '350104' }),
'3501043C03020050');
_chains['chain-ram'] = { rows: [] };
assert.strictEqual(
chainRamBuildRowHex(0, row('push', { pushType: '02', pushPort: '1F90' })),
'80EC0102053C03001F90');
});
test('CREATE FILE FCP template skeleton (TS 102 222 table 4)', () => {
_chains['chain-usim'] = { rows: [] };
const r1 = row('create-file', { fid: '6F07', structure: 'transparent', fileSize: '0010', lcsi: '05' });
_chains['chain-usim'].rows.push(r1);
chainBuildFcp('chain-usim', 0, 'create');
assert.strictEqual(r1.fields.fcp, '62138202012183026F078A01058C027F0080020010');
const r2 = row('create-file', { fid: '6F3A', structure: 'linear_fixed', recLen: '006E', fileSize: '0226', lcsi: '05' });
_chains['chain-usim'].rows.push(r2);
chainBuildFcp('chain-usim', 1, 'create');
assert.strictEqual(r2.fields.fcp, '621582040221006E83026F3A8A01058C027F0080020226');
// Record files default the file size to one record.
const r3 = row('create-file', { fid: '6F3A', structure: 'cyclic', recLen: '0010', fileSize: '' });
_chains['chain-usim'].rows.push(r3);
chainBuildFcp('chain-usim', 2, 'create');
assert.ok(r3.fields.fcp.endsWith('8002' + '0010'));
// RESIZE template: FID + file size only (table 17).
const r4 = row('resize-file', { fid: '6F07', fileSize: '0010' });
_chains['chain-usim'].rows.push(r4);
chainBuildFcp('chain-usim', 3, 'resize');
assert.strictEqual(r4.fields.fcp, '620883026F0780020010');
});
test('pushSectionApdus builds the guided §9 channel/link requests', () => {
// BIP channel opening: OPEN CHANNEL TLVs are optional.
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '350103' }), ['80EC010103350103']);
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '' }), ['80EC0101']);
// CAT_TP: the destination port is mandatory (9.2.2).
assert.deepStrictEqual(pushSectionApdus('link', { request: '02', pushPort: '1F90' }), ['80EC0102053C03001F90']);
assert.deepStrictEqual(pushSectionApdus('link', { request: '02', pushPort: '' }), []);
// TCP: port and destination address are mandatory (9.2.3).
const tcp = '80EC010319' + '3501033C030200503E05210A0000014708696E7465726E6574';
assert.deepStrictEqual(
pushSectionApdus('link', { request: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet' }),
[tcp]);
assert.deepStrictEqual(pushSectionApdus('link', { request: '03', pushPort: '0050' }), []);
assert.deepStrictEqual(pushSectionApdus('link', { request: '03', pushAddr: '210A000001' }), []);
// The identification packet (9.1.5) rides in the same message right after
// the TCP request; its data is optional (ICCID when absent).
assert.deepStrictEqual(
pushSectionApdus('link', { request: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet', withIdp: true }),
[tcp, '80EC0104']);
assert.deepStrictEqual(
pushSectionApdus('link', { request: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet', withIdp: true, idpIdent: '0102' }),
[tcp, '80EC01040436020102']);
// The follow-up only applies to the TCP request.
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withIdp: true }), ['80EC0101']);
// Stepping Stones R7 18.6: the BIP channel opening and the CAT_TP link
// request go in the same message; 01 carries the channel parameters, 02 the
// CAT_TP port (mandatory) plus optional max SDU / identification data.
assert.deepStrictEqual(
pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: true, pushPort: '1F90' }),
['80EC0101', '80EC0102053C03001F90']);
assert.deepStrictEqual(
pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: true, pushPort: '1F90', pushSdu: '0200', pushIdent: 'ABCD' }),
['80EC0101', '80EC01020D3C03001F90390202003602ABCD']);
// The CAT_TP port is mandatory for the pair, and the option only applies to 01.
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: true, pushPort: '' }), []);
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: false, pushPort: '1F90' }), ['80EC0101']);
});
test('pushOpenChannelTlvs composes the guided OPEN CHANNEL parameters', () => {
// The bearer defaults to the default bearer (35 01 03); the local address is
// never emitted (TS 102 226 9.2.1 / Stepping Stones 18.6.1).
assert.strictEqual(pushOpenChannelTlvs({}), '350103');
assert.strictEqual(pushOpenChannelTlvs({ ocBearer: '350104' }), '350104');
// Transport level (UDP or TCP client remote only, TS 102 223 6.6.27.4).
assert.strictEqual(pushOpenChannelTlvs({ ocProto: '01', ocPort: '1F90' }), '3501033C03011F90');
assert.strictEqual(pushOpenChannelTlvs({ ocProto: '02', ocPort: '0050' }), '3501033C03020050');
// Full set: transport level + destination address + NAA + buffer size +
// transparent alpha (05 00 = no user confirmation).
assert.strictEqual(
pushOpenChannelTlvs({ ocProto: '01', ocPort: '1F90', ocAddr: '210A000001', ocApn: 'internet',
ocBuffer: '0200', ocAlphaNull: true }),
'350103' + '3C03011F90' + '3E05210A000001' + '4708696E7465726E6574' + '39020200' + '0500');
// A custom alpha identifier and free-form extra TLVs (login/password, ...).
assert.strictEqual(pushOpenChannelTlvs({ ocAlpha: '00410042' }), '350103050400410042');
assert.strictEqual(pushOpenChannelTlvs({ ocExtra: '4708696E7465726E6574' }), '3501034708696E7465726E6574');
// Fields mode feeds the same encoder as the raw hex field.
assert.deepStrictEqual(
pushSectionApdus('link', { request: '01', ocMode: 'fields', ocProto: '01', ocPort: '1F90' }),
['80EC0101083501033C03011F90']);
assert.deepStrictEqual(
pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '350103' }),
['80EC010103350103']);
// Fields mode plus the CAT_TP follow-up: the OPEN CHANNEL port is its own
// field, the CAT_TP port comes from the shared one.
assert.deepStrictEqual(
pushSectionApdus('link', { request: '01', ocMode: 'fields', ocProto: '01', ocPort: '1F90',
withCat: true, pushPort: '1F91' }),
['80EC0101083501033C03011F90', '80EC0102053C03001F91']);
});
test('chainApduList drops GET RESPONSE and splits multi-APDU rows', () => {
_chains['ber-usim-1'] = { rows: [
row('read-binary', { offset: '0000', le: '00' }),
row('get-response', {}),
row('select', { method: 'chain', chain: '3F00,2FE2' }),
] };
// Single APDUs pass through; the SELECT chain row is split by the parser.
__split = [{ label: 'APDU', hex: '00A40000023F00' }, { label: 'APDU', hex: '00A40000022FE2' }];
const list = chainApduList('ber-usim-1');
assert.deepStrictEqual(list, ['00B0000000', '00A40000023F00', '00A40000022FE2']);
});
test('an embedded chain contributes a C-APDU TLV to the script', () => {
// genBerRowValue is covered in ber.test.js; here the chain-side contract:
// one row per C-APDU, built with the same builders as the stock chains.
_chains['ber-ram-9'] = { rows: [row('put-key', { keyVer: '01', keyId: '01', data: 'AA' })] };
assert.strictEqual(chainBuildRowHex('ber-ram-9', 0), '80D0010101AA');
});