v3.10.0: preset TAR table with per-TAR MSL; derive the SPI1 from the TAR

Presets drop the card-wide SPI1/SPI2 and the fixed tar/uiccTar/usimTar
fields: a preset now carries `tars` - the three mandatory role entries
(isd/uiccRfm/usimRfm, spec defaults pre-settled) plus free TARs with an
optional description, each with its MSL (Minimum SPI1, TS 102 226
8.2.1.3.2.4).  v3.9.1 presets convert on load/import (the spi1 seeds all
three MSLs); duplicate TARs and empty TAR/MSL values are refused; the
store schema version is 3.

Operations derive a packet's SPI1 from its TAR's MSL (_spi1_for_tar): the
RAM ops, the test runner (per-step overrides still win), the counter probe
and each TAR-probe row; the plain send keeps an explicit SPI1 with a
`warning` when it is below the MSL, and a TAR outside the preset without an
explicit SPI1 is refused with a hint.  The TAR probe resolves per checked
TAR before sending and reports the effective spi1 per row.  The secured
packet form's SPI2 default is 00 (the RAM/probe operations pin their own).

PWA: the Cards form edits the TAR table (role rows with MSL inputs plus
Add/Remove free rows with a description), the list shows a TARs (MSL)
column, the SCP80 TAR field suggests the preset's entries (datalist) and a
preset apply seeds the SPI1 from the operation TAR's MSL with an amber
warning below it; the preset-wide SPI1/SPI2 fields and the header marker's
SPI requirement are gone (completeness = a keyset + the three role entries
with their MSLs).  Help EN/RU, docs/api.md and AGENTS updated; sw cache
simple-v358.

Live: TAR probe 000000 at its MSL 16 (registered, por_ok, counter
advanced); a send without spi1 derived 16 (por_ok, counter advanced); a
send with spi1 0A returned the warning and the card refused it (6200, no
counter consumed); an unknown TAR without spi1 was refused with the hint.
This commit is contained in:
2026-10-02 08:28:46 +03:00
parent f059d05040
commit 6360dc5bae
19 changed files with 1187 additions and 269 deletions
+20 -4
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
@@ -46,6 +52,10 @@ code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n';
code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
code += extractFunc(html, 'cardsMatchedPreset') + '\n';
code += extractFunc(html, 'cardsAdmPresent') + '\n';
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') + extractConst(html, 'CARDS_MSL_DEFAULT');
code += extractFunc(html, 'cardsTarList') + '\n';
code += extractFunc(html, 'spKeysetList') + '\n';
code += extractFunc(html, 'spKeysetKvnOf') + '\n';
code += extractFunc(html, 'cardsScp80Complete') + '\n';
code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'pysimSetServerAvailable') + '\n';
@@ -339,8 +349,11 @@ test('the ADM badge marks an ADM key in the matching preset', () => {
test('the header SCP80/SCP81 markers follow the matching preset', () => {
const { scp80El, scp81El } = setup();
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }];
globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis.cardsFindByIccid = () => 0;
pysimCardStateUpdate(status({ connected: true, card_session: 2, iccid: '89701450001700031958' }));
assert.strictEqual(scp80El.textContent, 'SCP80');
@@ -356,8 +369,11 @@ test('the header SCP80/SCP81 markers follow the matching preset', () => {
test('losing the server hides the ADM badge and the preset markers', () => {
const { adm, scp80El, scp81El } = setup();
globalThis.cards = [{ name: 'C', adm: '0011', kic: '15', kid: '15', spi1: '16', spi2: '01',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }];
globalThis.cards = [{ name: 'C', adm: '0011', kic: '15', kid: '15',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis.cardsFindByIccid = () => 0;
pysimCardStateUpdate(status({ connected: true, adm_verified: true, iccid: '89701450001700031958' }));
assert.ok(!adm.classes.has('hidden'));
+19 -6
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
@@ -22,18 +28,23 @@ function extractFunc(src, name) {
}
let code = '';
for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply',
for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
'cardsTarEntry', 'cardsTarMsl', 'cardsApplyFields', 'cardsApply',
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard',
'spTarListRender',
'ramKeysetGuard',
]) {
code += extractFunc(html, fn) + '\n';
}
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT') + code;
code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n'
+ 'globalThis.spMslWarningRender = function() {};\n'
+ 'globalThis.pysimApplyAvailability = function() {};\n' + code;
code = 'var _genSpBuildStub = function() {};\n' + code;
eval(code);
@@ -52,10 +63,12 @@ function fakeEnv(selValue, selId, presetCntr) {
}
els[selId || 'sp-card-sel'] = { value: selValue };
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [{ name: 'C', cntr: presetCntr, spi1: '16', spi2: '01',
kic: '15', kid: '15', tar: '000000', uiccTar: 'B00000',
kicKey: 'AA', kidKey: 'BB' }];
globalThis._spTarKey = 'uiccTar';
globalThis.cards = [{ name: 'C', cntr: presetCntr,
kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB',
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis._spTarKey = 'uiccRfm';
globalThis.updateSpKic = () => {};
globalThis.updateSpKid = () => {};
globalThis.spInvalidate = () => {};
@@ -83,7 +96,7 @@ test('spRefreshFromPreset re-reads the edited preset before an operation', () =>
test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => {
const env = fakeEnv('0', 'ram-card-sel', '0000000010');
assert.strictEqual(spRefreshFromPreset('ram-card-sel'), '0000000010');
assert.strictEqual(_spTarKey, 'tar');
assert.strictEqual(_spTarKey, 'isd');
assert.strictEqual(env.els['sp-tar'].value, '000000');
});
+222 -67
View File
@@ -21,38 +21,57 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
let code = '';
const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove'];
for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEdit',
for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove',
'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr',
'cardsApplyFields', 'cardsApply', 'ramApplyCard',
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
'spTarKeyForPack', 'spPresetTar', 'packToSp',
'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp',
'esc', 'escHtml',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) {
'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'spMslWarningText',
'cardsTarRowHtml', 'cardsTarRowsRender', 'cardsTarAdd', 'cardsTarRemove',
'cardsFreeTarsFromForm', 'cardsTarsFromForm', 'cardsTarsReset']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n'
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT')
+ 'var _cardsKeysetCount = 0;\nvar _cardsTarCount = 0;\n'
+ 'globalThis.spPresetWarningRender = function() {};\n'
+ 'globalThis.spMslWarningRender = function() {};\n'
+ 'globalThis.pysimApplyAvailability = function() {};\n' + code;
eval(code);
globalThis.t = s => s;
const CARD_IDS = ['cards-name','cards-iccid','cards-adm',
'cards-spi1','cards-spi2','cards-tar','cards-uicc-tar','cards-usim-tar',
'cards-tar','cards-tar-msl','cards-uicc-tar','cards-uicc-tar-msl',
'cards-usim-tar','cards-usim-tar-msl','cards-tars',
'cards-psk-id','cards-psk-key','cards-keysets',
'cards-add-btn','cards-cancel-btn',
// the keyset editor rows (two rows is enough for the tests)
'cards-ks-0-kvn','cards-ks-0-kic','cards-ks-0-kid','cards-ks-0-kic-key',
'cards-ks-0-kid-key','cards-ks-0-cntr',
'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key',
'cards-ks-1-kid-key','cards-ks-1-cntr'];
'cards-ks-1-kid-key','cards-ks-1-cntr',
// the free TAR editor rows
'cards-ft-0-tar','cards-ft-0-msl','cards-ft-0-desc',
'cards-ft-1-tar','cards-ft-1-msl','cards-ft-1-desc'];
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex',
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex','sp-tar-list',
'sp-keyset-sel','ram-keyset-sel'];
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview',
'ber-result','hota-preview'];
@@ -65,6 +84,24 @@ function ksRow(els, i, ks) {
}
}
function tarRow(els, i, e) {
for (const field of ['tar', 'msl', 'desc']) {
const id = 'cards-ft-' + i + '-' + field;
if (!els[id]) els[id] = fakeEl();
els[id].value = e[field] || '';
}
}
function presetFixture(extra) {
return Object.assign({
name: 'C',
keysets: [{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }],
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }],
}, extra || {});
}
function fakeEl() {
return {
value: '',
@@ -86,7 +123,8 @@ function setup() {
globalThis._cardsEditIdx = null;
globalThis._ramCardIdx = null;
globalThis._cardsKeysetCount = 1;
globalThis._spTarKey = 'tar';
_cardsTarCount = 0;
globalThis._spTarKey = 'isd';
globalThis.t = s => s;
globalThis.esc = s => s;
globalThis.escHtml = s => s;
@@ -125,25 +163,58 @@ function setup() {
return els;
}
test('cardsTarValue falls back to the per-target spec defaults', () => {
assert.strictEqual(cardsTarValue('tar', '123456'), '123456');
assert.strictEqual(cardsTarValue('tar', ''), '000000');
assert.strictEqual(cardsTarValue('uiccTar', ''), 'B00000');
assert.strictEqual(cardsTarValue('usimTar', null), 'B00001');
assert.strictEqual(cardsTarValue('nope', ''), '');
test('the preset TAR table helpers read the role entries and the MSLs', () => {
const p = presetFixture({ tars: [
{ role: 'isd', tar: 'AF4D01', msl: '1A' },
{ role: 'uiccRfm', tar: 'B00000', msl: '10' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' },
{ tar: 'AF4D02', msl: '0A', desc: 'applet' }] });
assert.strictEqual(cardsRoleTar(p, 'isd'), 'AF4D01');
assert.strictEqual(cardsRoleMsl(p, 'isd'), '1A');
assert.strictEqual(cardsTarMsl(p, 'af4d02'), '0A');
assert.strictEqual(cardsTarMsl(p, '123456'), '');
assert.strictEqual(cardsTarEntry(p, 'AF4D02').desc, 'applet');
// a missing role entry falls back to the spec default
assert.strictEqual(cardsRoleTar({}, 'uiccRfm'), 'B00000');
assert.strictEqual(cardsRoleTar(null, 'usimRfm'), 'B00001');
assert.strictEqual(cardsRoleDefault('nope'), '');
assert.strictEqual(cardsTarText(p),
'ISD AF4D01\u00b71A UICC B00000\u00b710 ADF B00001\u00b716 AF4D02\u00b70A (applet)');
assert.strictEqual(cardsTarText({}), '\u2014');
});
test('spMslWarningText compares the SPI1 with the TAR MSL numerically', () => {
const p = presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '10' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] });
assert.strictEqual(spMslWarningText(p, '000000', '10'), '');
assert.strictEqual(spMslWarningText(p, '000000', '1A'), '');
assert.match(spMslWarningText(p, '000000', '0A'), /MSL/);
// numeric, not lexicographic: '9' < '10'
assert.match(spMslWarningText(p, '000000', '9'), /MSL/);
// a TAR the preset does not carry (or a missing SPI1) is not a warning
assert.strictEqual(spMslWarningText(p, 'AF4D01', '00'), '');
assert.strictEqual(spMslWarningText(p, '000000', ''), '');
});
test('preset completeness predicates used by the header markers', () => {
const full = { kic: '15', kid: '15', spi1: '16', spi2: '01', cntr: '0000000001',
kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY', adm: '0011' };
const full = presetFixture({ pskIdentity: 'id', pskKey: 'KEY', adm: '0011' });
assert.ok(cardsScp80Complete(full));
assert.ok(cardsScp81Complete(full));
assert.ok(cardsAdmPresent(full));
// every SCP80 field is required; whitespace counts as empty
for (const field of ['kic', 'kid', 'spi1', 'spi2', 'cntr', 'kicKey', 'kidKey']) {
const c = Object.assign({}, full);
c[field] = ' ';
assert.ok(!cardsScp80Complete(c), 'SCP80 marked complete with empty ' + field);
// a complete keyset is required
assert.ok(!cardsScp80Complete(Object.assign({}, full, { keysets: [] })));
assert.ok(!cardsScp80Complete(Object.assign({}, full, {
keysets: [{ kic: '15', kid: '15', kicKey: '', kidKey: 'BB', cntr: '0000000001' }] })));
// every role entry needs its TAR and MSL
for (const role of ['isd', 'uiccRfm', 'usimRfm']) {
const noMsl = presetFixture({ tars: full.tars.map(e =>
e.role === role ? Object.assign({}, e, { msl: '' }) : e) });
assert.ok(!cardsScp80Complete(noMsl), 'SCP80 complete with an empty MSL for ' + role);
const noTar = presetFixture({ tars: full.tars.map(e =>
e.role === role ? Object.assign({}, e, { tar: '' }) : e) });
assert.ok(!cardsScp80Complete(noTar), 'SCP80 complete with an empty TAR for ' + role);
}
assert.ok(!cardsScp81Complete({ pskIdentity: 'id' }));
assert.ok(!cardsScp81Complete({ pskKey: 'KEY' }));
@@ -153,60 +224,82 @@ test('preset completeness predicates used by the header markers', () => {
assert.ok(!cardsAdmPresent({ adm: ' ' }));
});
test('clearing the card form keeps the TAR spec defaults', () => {
test('clearing the card form keeps the role TAR/MSL defaults', () => {
const els = setup();
els['cards-tar'].value = 'AABBCC';
els['cards-tar-msl'].value = 'FF';
els['cards-uicc-tar'].value = 'AABBCC';
els['cards-usim-tar'].value = 'AABBCC';
els['cards-adm'].value = 'DEAD';
els['cards-name'].value = 'X';
tarRow(els, 0, { tar: 'AF4D01', msl: '0A', desc: 'applet' });
_cardsTarCount = 1;
cardsClearForm();
assert.strictEqual(els['cards-tar'].value, '000000');
assert.strictEqual(els['cards-tar-msl'].value, '16');
assert.strictEqual(els['cards-uicc-tar'].value, 'B00000');
assert.strictEqual(els['cards-uicc-tar-msl'].value, '16');
assert.strictEqual(els['cards-usim-tar'].value, 'B00001');
assert.strictEqual(els['cards-adm'].value, '');
assert.strictEqual(els['cards-name'].value, '');
assert.strictEqual(_cardsTarCount, 0, 'the free TAR rows are cleared');
});
test('editing prefills empty stored TARs with the spec defaults', () => {
test('editing prefills the role TARs/MSLs and renders the free TAR rows', () => {
const els = setup();
globalThis.cards = [
{ name: 'Legacy', kic: '15', kid: '15', tar: '' },
{ name: 'Full', kic: '15', kid: '15', tar: '000001', uiccTar: 'B0000C', usimTar: 'B0000D', adm: '0011' },
{ name: 'Legacy', keysets: [] },
presetFixture({ name: 'Full', adm: '0011', tars: [
{ role: 'isd', tar: '000001', msl: '0A' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
{ role: 'usimRfm', tar: 'B0000D', msl: '16' },
{ tar: 'AF4D02', msl: '1A', desc: 'applet' }] }),
];
cardsEdit(0);
assert.strictEqual(els['cards-tar'].value, '000000');
assert.strictEqual(els['cards-tar-msl'].value, '');
assert.strictEqual(els['cards-uicc-tar'].value, 'B00000');
assert.strictEqual(els['cards-usim-tar'].value, 'B00001');
assert.strictEqual(els['cards-adm'].value, '');
cardsEdit(1);
assert.strictEqual(els['cards-tar'].value, '000001');
assert.strictEqual(els['cards-tar-msl'].value, '0A');
assert.strictEqual(els['cards-uicc-tar'].value, 'B0000C');
assert.strictEqual(els['cards-usim-tar'].value, 'B0000D');
assert.strictEqual(els['cards-usim-tar-msl'].value, '16');
assert.strictEqual(els['cards-adm'].value, '0011');
assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /cards-ft-0-tar"[^>]*value="AF4D02"/);
assert.match(els['cards-tars'].innerHTML, /cards-ft-0-desc"[^>]*value="applet"/);
});
test('the form values carry the ADM and per-target TAR fields', () => {
test('the form values carry the ADM and the TAR table', () => {
const els = setup();
els['cards-tar'].value = ' 000001 ';
els['cards-uicc-tar'].value = 'B00002';
els['cards-tar-msl'].value = '0a';
els['cards-uicc-tar'].value = '';
els['cards-uicc-tar-msl'].value = '';
els['cards-usim-tar'].value = 'B00003';
els['cards-usim-tar-msl'].value = '1A';
els['cards-adm'].value = ' 00 11 22 33 ';
tarRow(els, 0, { tar: 'af4d02', msl: '0A', desc: 'My applet' });
_cardsTarCount = 1;
const v = cardsFormValues();
assert.strictEqual(v.tar, '000001');
assert.strictEqual(v.uiccTar, 'B00002');
assert.strictEqual(v.usimTar, 'B00003');
assert.deepStrictEqual(v.tars, [
{ role: 'isd', tar: '000001', msl: '0A', desc: '' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '' },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' },
]);
assert.strictEqual(v.adm, '00112233');
});
test('saving a preset posts the form with spec-default TARs', async () => {
test('saving a preset posts the form with the pre-settled TAR table', async () => {
const els = setup();
els['cards-name'].value = 'New card';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
globalThis._cardsKeysetCount = 1;
els['cards-tar'].value = '';
els['cards-uicc-tar'].value = '';
els['cards-usim-tar'].value = '';
// the role fields are pre-settled by the markup; the test's fake elements
// start empty - the defaults fill in
els['cards-adm'].value = 'aa bb';
await cardsAdd();
assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls));
@@ -214,16 +307,37 @@ test('saving a preset posts the form with spec-default TARs', async () => {
assert.strictEqual(apiCalls[0].body.name, 'New card');
assert.deepStrictEqual(apiCalls[0].body.keysets, [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' }]);
assert.strictEqual(apiCalls[0].body.tar, '000000');
assert.strictEqual(apiCalls[0].body.uiccTar, 'B00000');
assert.strictEqual(apiCalls[0].body.usimTar, 'B00001');
assert.deepStrictEqual(apiCalls[0].body.tars, [
{ role: 'isd', tar: '000000', msl: '16', desc: '' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '' },
]);
assert.strictEqual(apiCalls[0].body.adm, 'AABB');
});
test('saving refuses a free TAR row without its MSL or with a duplicate TAR', async () => {
const els = setup();
els['cards-name'].value = 'New card';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
tarRow(els, 0, { tar: 'AF4D02', msl: '', desc: '' });
_cardsTarCount = 1;
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /MSL/);
// a TAR may appear once
seen.length = 0;
tarRow(els, 0, { tar: '000000', msl: '16', desc: '' });
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /Duplicate TAR/);
});
test('editing a preset updates it by its store id', async () => {
const els = setup();
globalThis.cards = [{ id: 'abc123', name: 'Old', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
globalThis.cards = [presetFixture({ id: 'abc123', name: 'Old' })];
globalThis._cardsEditIdx = 0;
els['cards-name'].value = 'Renamed';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
@@ -232,6 +346,7 @@ test('editing a preset updates it by its store id', async () => {
assert.strictEqual(apiCalls[0].path, '/api/presets/update');
assert.strictEqual(apiCalls[0].body.id, 'abc123');
assert.strictEqual(apiCalls[0].body.fields.name, 'Renamed');
assert.strictEqual(apiCalls[0].body.fields.tars.length, 3);
});
test('removing a preset deletes it in the store', async () => {
@@ -284,72 +399,91 @@ test('the SCP80 counter edit is written to the store, a reported counter only lo
assert.strictEqual(apiCalls.length, 0);
});
test('applying a preset uses the TAR of the current operation', () => {
test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => {
const els = setup();
globalThis.cards = [{
name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01',
tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB',
}];
globalThis._spTarKey = 'tar';
globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '0A' },
{ role: 'usimRfm', tar: 'B0000D', msl: '1A' }] })];
globalThis._spTarKey = 'isd';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, '000000');
globalThis._spTarKey = 'uiccTar';
assert.strictEqual(els['sp-spi1'].value, '16');
globalThis._spTarKey = 'uiccRfm';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B0000C');
globalThis._spTarKey = 'usimTar';
assert.strictEqual(els['sp-spi1'].value, '0A');
globalThis._spTarKey = 'usimRfm';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B0000D');
// empty stored TAR -> spec default
globalThis.cards = [{ name: 'D', kic: '15', kid: '15', tar: '' }];
globalThis._spTarKey = 'tar';
assert.strictEqual(els['sp-spi1'].value, '1A');
// an empty stored TAR -> spec default
globalThis.cards = [{ name: 'D', keysets: [], tars: [] }];
globalThis._spTarKey = 'isd';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, '000000');
assert.strictEqual(els['sp-spi1'].value, '16');
});
test('RAM card selection forces the ISD TAR', () => {
const els = setup();
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D' }];
globalThis._spTarKey = 'usimTar';
globalThis.cards = [presetFixture()];
globalThis._spTarKey = 'usimRfm';
ramApplyCard('0');
assert.strictEqual(globalThis._spTarKey, 'tar');
assert.strictEqual(globalThis._spTarKey, 'isd');
assert.strictEqual(els['sp-tar'].value, '000000');
});
test('spTarKeyForPack maps each chain to its preset TAR field', () => {
assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'tar');
assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccTar');
assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimTar');
test('spTarKeyForPack maps each chain to its preset TAR role', () => {
assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'isd');
assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccRfm');
assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimRfm');
assert.strictEqual(spTarKeyForPack('ber-result'), null);
assert.strictEqual(spTarKeyForPack('hota-preview'), null);
});
test('spPresetTar reads the selected preset and falls back to the spec defaults', () => {
const els = setup();
globalThis.cards = [{ name: 'A', tar: '000000', uiccTar: 'B0000A' }];
globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000A', msl: '16' }] })];
els['sp-card-sel'].value = '0';
assert.strictEqual(spPresetTar('uiccTar'), 'B0000A');
assert.strictEqual(spPresetTar('usimTar'), 'B00001');
assert.strictEqual(spPresetTar('uiccRfm'), 'B0000A');
assert.strictEqual(spPresetTar('usimRfm'), 'B00001');
els['sp-card-sel'].value = '';
assert.strictEqual(spPresetTar('uiccTar'), 'B00000');
assert.strictEqual(spPresetTar('uiccRfm'), 'B00000');
});
test('the TAR field suggestions come from the preset table', () => {
const els = setup();
spTarListRender(presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }));
assert.match(els['sp-tar-list'].innerHTML, /value="000000"[^>]*label="ISD \(MSL 16\)"/);
assert.match(els['sp-tar-list'].innerHTML, /value="AF4D02"[^>]*label="My applet \(MSL 0A\)"/);
spTarListRender(null);
assert.strictEqual(els['sp-tar-list'].innerHTML, '');
});
test('packing a chain selects the TAR of its target', () => {
const els = setup();
globalThis.cards = [{ name: 'C', tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D' }];
globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
{ role: 'usimRfm', tar: 'B0000D', msl: '16' }] })];
els['sp-card-sel'].value = '0';
els['chain-sim-preview'].value = 'A0A4000002';
packToSp('chain-sim-preview');
assert.strictEqual(globalThis._spTarKey, 'uiccTar');
assert.strictEqual(globalThis._spTarKey, 'uiccRfm');
assert.strictEqual(els['sp-tar'].value, 'B0000C');
assert.strictEqual(els['sp-apdu'].value, 'A0A4000002');
els['chain-usim-preview'].value = '00A4000002';
packToSp('chain-usim-preview');
assert.strictEqual(globalThis._spTarKey, 'usimTar');
assert.strictEqual(globalThis._spTarKey, 'usimRfm');
assert.strictEqual(els['sp-tar'].value, 'B0000D');
els['chain-ram-preview'].value = '80E29000';
packToSp('chain-ram-preview');
assert.strictEqual(globalThis._spTarKey, 'tar');
assert.strictEqual(globalThis._spTarKey, 'isd');
assert.strictEqual(els['sp-tar'].value, '000000');
// BER / HTTP OTA packs leave the TAR alone
els['sp-tar'].value = 'ABCDEF';
@@ -371,3 +505,24 @@ test('packing without a selected preset uses the spec default TAR', () => {
packToSp('chain-ram-preview');
assert.strictEqual(els['sp-tar'].value, '000000');
});
test('the TAR editor renders, adds and removes free rows', () => {
const els = setup();
cardsTarRowsRender([{ tar: 'AF4D01', msl: '0A', desc: 'one' },
{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
assert.strictEqual(_cardsTarCount, 2);
assert.match(els['cards-tars'].innerHTML, /cards-ft-1-tar"[^>]*value="AF4D02"/);
// simulate the DOM the innerHTML produced
tarRow(els, 0, { tar: 'AF4D01', msl: '0A', desc: 'one' });
tarRow(els, 1, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarRemove(0);
assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /value="AF4D02"/);
assert.doesNotMatch(els['cards-tars'].innerHTML, /value="AF4D01"/);
tarRow(els, 0, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarAdd();
assert.strictEqual(_cardsTarCount, 2);
// the new row renders empty
tarRow(els, 1, { tar: '', msl: '', desc: '' });
assert.deepStrictEqual(cardsFreeTarsFromForm(), [{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
});
+13 -3
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
@@ -25,9 +31,13 @@ let code = '';
for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
'cardsIccidFromCard', 'cardsAutoSelectByIccid', 'cardsRender',
'esc', 'spKeysetList', 'spKeysetKvnOf', 'cardsTarValue', 'cardsScp81Complete']) {
'esc', 'spKeysetList', 'spKeysetKvnOf',
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'cardsScp81Complete']) {
code += extractFunc(html, fn) + '\n';
}
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT');
eval(code);
// live-card style raw EF.ICCID content (nibble-swapped digits + 'F' pad)
@@ -176,8 +186,8 @@ test('cardsRender highlights the equipped card preset', () => {
const els = { 'cards-tbody': { innerHTML: '' } };
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [
{ name: 'other', iccid: '1111111111111111111', spi1: '16', spi2: '01', keysets: [] },
{ name: 'live', iccid: DIGITS, spi1: '16', spi2: '01', keysets: [] },
{ name: 'other', iccid: '1111111111111111111', keysets: [] },
{ name: 'live', iccid: DIGITS, keysets: [] },
];
globalThis._pysimCardIccid = DIGITS;
cardsRender();
+7 -2
View File
@@ -144,8 +144,13 @@ test('the card form groups the SCP80 and SCP81 fields into labelled fieldsets',
assert.match(scp80, />ISD TAR<\/label>/);
assert.match(scp80, />UICC RFM TAR<\/label>/);
assert.match(scp80, />ADF RFM TAR<\/label>/);
assert.ok(scp80.indexOf('id="cards-spi1"') < scp80.indexOf('id="cards-tar"'),
'TAR fields must follow the SPI fields');
// the preset-wide SPI1/SPI2 inputs are gone: each TAR carries its MSL
assert.ok(!scp80.includes('id="cards-spi1"'), 'the preset-wide SPI1 input is gone');
assert.ok(!scp80.includes('id="cards-spi2"'), 'the preset-wide SPI2 input is gone');
assert.ok(scp80.indexOf('id="cards-tar"') < scp80.indexOf('id="cards-tar-msl"'),
'the ISD MSL input follows its TAR input');
assert.match(scp80, /id="cards-tars"/);
assert.match(scp80, /onclick="cardsTarAdd\(\)"/);
assert.match(scp81, /id="cards-psk-id"/);
assert.match(scp81, /id="cards-psk-key"/);
// the PSK explanation lives inside the SCP81 group, not outside it
+13 -2
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
@@ -31,6 +37,8 @@ code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
code += extractFunc(html, 'pysimUpdateIccidIndicator') + '\n';
code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n';
code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') + extractConst(html, 'CARDS_MSL_DEFAULT');
code += extractFunc(html, 'cardsTarList') + '\n';
code += extractFunc(html, 'cardsScp80Complete') + '\n';
code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'cardsMatchedPreset') + '\n';
@@ -176,8 +184,11 @@ test('header prints the equipped card ICCID next to the card image', () => {
test('SCP80/SCP81 markers follow the matching preset completeness', () => {
const els = setup();
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }];
globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis.cardsFindByIccid = () => 0;
_pysimCardIccid = '89701450001700031958';
pysimUpdatePresetIndicators();