Compare commits

...

4 Commits

Author SHA1 Message Date
catarrh 71d2e80242 C-APDU parser: P1/P2 descriptors, INSTALL LV decode, structural fixes; v1.9.1
Parser labels (§8.3, verified against GPC v2.3 / TS 102 221 PDFs):
- describeP1P2: SELECT P1/P2 (FID/DF-name/path-MF/path-DF; FCP/no-data),
  READ/UPDATE RECORD modes (next/previous/absolute + SFI, P1-ignored note),
  GET STATUS P1 (ISD/Apps/ELF/ELF+Modules) and P2 formats,
  INSTALL P1 bit-aware roles ('for install + for make selectable'),
  SET STATUS P1 (80 ISD / 40 App-or-SSD / 60 SD+associated) with
  card states vs lock/unlock P2, VERIFY/CHANGE PIN ref

Parser structure (§8.4):
- describeInstallDataLv: exact-sum LV walker for all 5 INSTALL layouts
  with privilege bit names and params tag nesting (C9, EF->CA, EA->80);
  falls back to legacy TLV view when lengths do not sum exactly
- GET DATA: case-2 (P3=Le) vs case-4 (Lc + tag list + Le)
- SET STATUS data: 'ignored for ISD' / raw AID / legacy 4F-TLV labeled
- Trailing single byte consumed as Le at end of compact chain
- ACTIVATE/DEACTIVATE: case-1 (4 bytes), legacy empty-Lc, FID/path forms
- Expanded script C-APDU rows now decode into structured APDU nodes
- Compact matcher accepts CLA 84-87 (GP secure messaging)

Version 1.9.0 -> 1.9.1, SW cache otaman-v10 -> otaman-v11
2026-08-22 08:36:20 +03:00
catarrh 7870682f3d Spec-truth pass: decodeTextData disambiguation, privilege tables per GPC v2.3
decodeTextData: legacy no-DCS UCS2 text whose first byte is 00/04 was
misrouted into DCS-aware branches (e.g. 'HI' = 00480049 decoded as GSM7
garbage). Now: dcs=08 strict (fail = '?', no reinterpretation); ambiguous
lead byte 00/04 prefers whole-buffer UCS2 when >=8 hex chars, %4==0,
printable, uniform high bytes; else DCS-aware path; legacy fallback kept.

Privileges per GPC v2.3 Tables 11-7/11-8/11-9 (verified against PDF):
- decodePrivileges rewritten table-driven; fixes wrong byte-2 mapping
  (was: Global Lock/Final Application/Receipt Generation on wrong bits)
  and byte-1 Card Terminate/Card Reset swap
- Reverted 'Token Management' rename: spec uses 'Token Verification'
  (66x vs single Table 11-8 outlier); checkbox + parser restored
- PLAY TONE qualifiers aligned to TS 102 223 §8.6 exact wording

Registry docs/uicc/UICC_SPECS.md corrected from primary sources:
- SET STATUS P1: '20' app/suppl SD -> '40' (GPC Table 11-86)
- SELECT P2 response field is b4b3 ('01' FCP / '11' none), not b3b2b1
  (TS 102 221 Table 11.2)
2026-08-22 08:14:06 +03:00
catarrh 0fd91d7771 C-APDU parser + BER fixes: DCS prefix, qualifier tables, EFRMA refs, CLA labels; v1.9.0
Parser + BER constructor (TS 102 223 / TS 102 226):
- DCS byte: genBerPcValue prepends DCS to 8D (00=GSM7, 08=UCS2, 04=unpacked)
- decodeTextData: consume DCS first, legacy no-DCS fallback kept
- BER_QUAL.display['80']: 'Clear after delay' -> 'Wait for user to clear message'
- BER_QUAL.tone: 'Monotonous/Alternating' -> 'Vibrate alert' names
- describeProactiveCommand: map type byte -> correct BER_QUAL key
  (01->refresh, 21->display, 20->tone); proper command names
- parseActionRow: values 01-7F -> 'Reference to EFRMA record'
- parseOneApdu: CLA 84-87 -> 'GlobalPlatform (secure messaging)'
- PARSE_INS: added GET RESPONSE (INS C0)

Version: 1.8.1 -> 1.9.0, SW cache otaman-v9 -> otaman-v10
2026-08-21 22:53:16 +03:00
catarrh 288c2e5954 C-APDU constructor spec fixes (TS 102 221 / GPC v2.3 / TS 102 226)
SIM/USIM tab (TS 102 221):
- Record P2 modes: 04/06/02 -> 04/02/03; P1=00 for next/previous
- SELECT Le rules: USIM FID no Le, path/dfname P2=04+Le; SIM no Le
- Path placeholder: 3F007FFF6FC5 -> 7FFF6FC5
- ACTIVATE/DEACTIVATE: 5-byte -> 4-byte case-1
- VERIFY PIN: dedicated input, Lc=08, FF-pad to 8 bytes
- CHANGE PIN: old/new inputs, Lc=10, two 8-byte fields
- ERASE BINARY removed from USIM tab

RAM tab (GPC v2.3):
- INSTALL: all 5 variants rewritten to strict LV + Le 00
- ELF/Module AID inputs for install-install
- Privileges: 1 byte if only byte-1, else 3 bytes
- InstallParams: C9 00 + toolkit TLV (mandatory)
- LOAD/DELETE/GET STATUS: append Le 00
- GET STATUS P2: drop 40/42, keep 02/03/00
- GET DATA: 2F00/5031 -> case-4 5C00 data
- SET STATUS: raw AID (no 4F), ISD card states, P1=60
- EXT AUTH: CLA 84, P1 security level, Lc=10
- INT AUTH: CLA 00

Toolkit (TS 102 226):
- MSL: 01 <msl> -> 00 (no check) or 02 01 <msl>
- SIM CA wrapped in EF: C9 00 EF <len> CA...
- TAR length %3 validation
- Menu IDs <= 7F validation

Script Chaining (TS 102 226 Table 5.9a):
- Removed Script ID + Additional Data fields
- Emit strictly 83 01 <flags>
- Deleted generateScriptIdHint

Privilege label: Token Verification -> Token Management
2026-08-21 22:37:39 +03:00
8 changed files with 700 additions and 243 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ Returns server version for compatibility checking.
**Example response:** **Example response:**
```json ```json
{"version": "1.8.1"} {"version": "1.9.1"}
``` ```
### `GET /api/status` ### `GET /api/status`
+529 -226
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v9'; const CACHE = 'otaman-v11';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+154
View File
@@ -170,7 +170,161 @@ test('gsm7Decode unpacks "HI" from C824', () => {
assert.strictEqual(gsm7Decode(bytes), 'HI'); assert.strictEqual(gsm7Decode(bytes), 'HI');
}); });
test('decodeTextData legacy UCS2 without DCS (regression: lead 00 is not a DCS)', () => {
assert.strictEqual(decodeTextData('00480049'), 'HI');
});
test('decodeTextData legacy Cyrillic UCS2 (uniform 04 high bytes)', () => {
assert.strictEqual(decodeTextData('041f04400438043204350442'), 'Привет');
});
test('decodeTextData DCS-prefixed UCS2 (08)', () => {
assert.strictEqual(decodeTextData('0800480049'), 'HI');
});
test('decodeTextData DCS-prefixed GSM7 packed (00)', () => {
assert.strictEqual(decodeTextData('00C824'), 'HI');
});
test('decodeTextData DCS-prefixed unpacked 8-bit (04)', () => {
assert.strictEqual(decodeTextData('04414243'), 'ABC');
});
test('decodeTextData explicit DCS 08 with malformed payload returns ?', () => {
assert.strictEqual(decodeTextData('0841'), '?');
});
test('decodePrivileges byte 2 b6 is Token Verification per GPC v2.3 Table 11-8', () => {
assert.ok(decodePrivileges('0020').includes('Token Verification'));
});
test('decodePrivileges', () => { test('decodePrivileges', () => {
assert.ok(decodePrivileges('00').includes('None')); assert.ok(decodePrivileges('00').includes('None'));
assert.ok(decodePrivileges('80').includes('Security Domain')); assert.ok(decodePrivileges('80').includes('Security Domain'));
}); });
test('LV INSTALL [for install] decode with labeled fields and trailing Le', () => {
const tree = parseHexTree('80E60C0011000008A000000151000000010002C9000000');
const apdu = tree.children[0];
assert.ok(findNode(apdu, 'P1').desc.includes('for install + for make selectable'));
const elf = findNode(apdu, 'ELF AID');
assert.ok(elf);
assert.strictEqual(elf.desc, '(empty)');
assert.strictEqual(findNode(apdu, 'Application AID').desc, 'A000000151000000');
assert.strictEqual(findNode(apdu, 'Privileges').desc, 'None');
const params = findNode(apdu, 'Install parameters');
assert.ok(params.children.some(c => c.label.includes('C9')));
assert.strictEqual(findNode(apdu, 'Install token').desc, '(empty)');
assert.strictEqual(tree.children[1].label, 'Le');
});
test('Legacy TLV INSTALL falls back to raw Data node', () => {
const tree = parseHexTree('80E60C00214F08A000000151000000C70100EA13801100000002010102020002011603B0000100');
const apdu = tree.children[0];
const data = findNodes(apdu, 'Data');
assert.strictEqual(data.length, 1);
assert.ok(!data[0].children || !data[0].label.includes('ELF'));
assert.ok(findNode(apdu, 'P1').desc.includes('for install + for make selectable'));
});
test('Install parameters EF nesting exposes inner CA TLV', () => {
const tree = parseHexTree('80E60C0025000008A000000151000000010016C900EF12CA1000000000030101000003030002011600' + '00');
const apdu = tree.children[0];
const params = findNode(apdu, 'Install parameters');
assert.ok(params.children.some(c => c.label.includes('EF')));
const ef = params.children.find(c => c.label.includes('EF'));
assert.ok(ef.children.some(c => c.label.includes('CA')));
});
test('GET DATA case 2 renders P3 as Le', () => {
const tree = parseHexTree('80CA5F5000');
const apdu = tree.children[0];
assert.ok(findNode(apdu, 'Le'));
assert.ok(!findNode(apdu, 'Lc'));
});
test('GET DATA case 4 decodes Lc + tag list + Le', () => {
const tree = parseHexTree('80CA2F00025C0000');
const apdu = tree.children[0];
assert.ok(findNode(apdu, 'Lc'));
assert.strictEqual(findNode(apdu, 'Data').desc, 'Tag list: 5C00');
assert.ok(findNode(apdu, 'Le'));
});
test('SET STATUS raw AID data labeled', () => {
const tree = parseHexTree('80F0408008A000000151000000');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'P1').desc, 'Application or SSD');
assert.strictEqual(findNode(apdu, 'P2').desc, 'LOCKED');
assert.ok(findNode(apdu, 'Data').desc.startsWith('AID (raw):'));
});
test('SET STATUS ISD card state label', () => {
const tree = parseHexTree('80F0807F00');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'P1').desc, 'ISD');
assert.strictEqual(findNode(apdu, 'P2').desc, 'CARD_LOCKED');
assert.ok(!findNode(apdu, 'Data'));
});
test('Trailing single byte consumed as Le in compact chain', () => {
const tree = parseHexTree('A0A40000026F3BDC0102032B2F2D00');
const last = tree.children[tree.children.length - 1];
assert.strictEqual(last.label, 'Le');
assert.strictEqual(last.hex, '00');
});
test('ACTIVATE FILE case-1 (4 bytes)', () => {
const tree = parseHexTree('00440000');
const apdu = tree.children[0];
assert.strictEqual(apdu.hex, '00440000');
assert.strictEqual(apdu.desc, 'no data, no Le');
assert.ok(!findNode(apdu, 'P3'));
assert.ok(!findNode(apdu, 'Lc'));
});
test('ACTIVATE FILE legacy 5-byte empty-Lc form', () => {
const tree = parseHexTree('0044000000');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'P3').desc, 'empty Lc (legacy form)');
});
test('ACTIVATE FILE with FID data', () => {
const tree = parseHexTree('00440000026F3B');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'Data').desc, 'FID 6F3B');
});
test('READ RECORD next mode: P1 ignored note', () => {
const tree = parseHexTree('00B2000200');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'P1').desc, 'ignored');
assert.ok(findNode(apdu, 'P2').desc.includes('next record'));
});
test('UPDATE RECORD absolute mode with record number', () => {
const tree = parseHexTree('00DC010404AABBCCDD');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'P1').desc, 'record #1');
assert.strictEqual(findNode(apdu, 'P2').desc, 'absolute/current');
});
test('SELECT P1/P2 descriptors', () => {
const tree = parseHexTree('00A40804047FFF6FC500');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'P1').desc, 'path from MF');
assert.ok(findNode(apdu, 'P2').desc.includes('FCP'));
});
test('Immediate Action EFRMA reference (01-7F)', () => {
const tree = parseHexTree('AA03810105');
const row = tree.children[0].children[0];
assert.strictEqual(row.label, 'Reference to EFRMA record');
assert.strictEqual(row.desc, 'Record 0x05');
});
test('CLA 84-87 labeled GlobalPlatform secure messaging', () => {
const tree = parseHexTree('8482030010' + '00112233445566778899AABBCCDDEEFF');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'CLA').desc, 'GlobalPlatform (secure messaging)');
});
+3 -3
View File
@@ -148,14 +148,14 @@ test('genBerPcValue DISPLAY TEXT GSM7 "HI"', () => {
pc.children.find(c => c.cls === 'ber-pc-enc').value = 'gsm7'; pc.children.find(c => c.cls === 'ber-pc-enc').value = 'gsm7';
pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false; pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false;
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81'); const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
assert.strictEqual(val, '810D8103012101820281828D02C824'); assert.strictEqual(val, '810E8103012101820281828D0300C824');
}); });
test('genBerPcValue DISPLAY TEXT UCS2 "HI" + 30s duration', () => { test('genBerPcValue DISPLAY TEXT UCS2 "HI" + 30s duration', () => {
const pc = buildPcHtml(); const pc = buildPcHtml();
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81'); const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
// 81 13 8103 01 21 01 8202 81 82 8D 04 0048 0049 84 02 01 1E // 81 13 8103 01 21 01 8202 81 82 8D 04 0048 0049 84 02 01 1E
assert.strictEqual(val, '81138103012101820281828D04004800498402011E'); assert.strictEqual(val, '81148103012101820281828D0508004800498402011E');
}); });
test('genBerPcValue duration disabled omits 84', () => { test('genBerPcValue duration disabled omits 84', () => {
@@ -208,7 +208,7 @@ test('genErrorActionValue proactive wraps proactive command', () => {
row.children[1].children.push(pc); row.children[1].children.push(pc);
const val = genErrorActionValue(row, '82'); const val = genErrorActionValue(row, '82');
// DISPLAY TEXT UCS2 "HI" + 30s inside Error Action // DISPLAY TEXT UCS2 "HI" + 30s inside Error Action
assert.strictEqual(val, '82138103012101820281828D04004800498402011E'); assert.strictEqual(val, '82148103012101820281828D0508004800498402011E');
}); });
test('genScriptChainingValue first emits 01', () => { test('genScriptChainingValue first emits 01', () => {
+8 -8
View File
@@ -87,7 +87,7 @@ function genRamApdu() {
test('UICC toolkit nested inside EA (m=2, services 0)', () => { test('UICC toolkit nested inside EA (m=2, services 0)', () => {
const apdu = genRamApdu(); const apdu = genRamApdu();
assert.ok(apdu.includes('EA128010000000020101020200011603B0000100'), apdu); assert.ok(apdu.includes('EA13801100000002010102020002011603B0000100'), apdu);
}); });
test('UICC m=1 emits single pair', () => { test('UICC m=1 emits single pair', () => {
@@ -111,7 +111,7 @@ test('UICC m=1 emits single pair', () => {
set('ram-tk-ad', ''); set('ram-tk-ad', '');
set('ram-tk-services', '0'); set('ram-tk-services', '0');
const apdu = genRamResult(); const apdu = genRamResult();
assert.ok(apdu.includes('EA10800E00000001010100011603B0000100'), apdu); assert.ok(apdu.includes('EA11800F0000000101010002011603B0000100'), apdu);
}); });
test('UICC m=3 fills middle pair with 0000', () => { test('UICC m=3 fills middle pair with 0000', () => {
@@ -135,7 +135,7 @@ test('UICC m=3 fills middle pair with 0000', () => {
set('ram-tk-ad', ''); set('ram-tk-ad', '');
set('ram-tk-services', '0'); set('ram-tk-services', '0');
const apdu = genRamResult(); const apdu = genRamResult();
assert.ok(apdu.includes('EA1480120000000301010000030300011603B0000100'), apdu); assert.ok(apdu.includes('EA158013000000030101000003030002011603B0000100'), apdu);
}); });
test('UICC services 7 appended as final byte', () => { test('UICC services 7 appended as final byte', () => {
@@ -159,7 +159,7 @@ test('UICC services 7 appended as final byte', () => {
set('ram-tk-ad', ''); set('ram-tk-ad', '');
set('ram-tk-services', '7'); set('ram-tk-services', '7');
const apdu = genRamResult(); const apdu = genRamResult();
assert.ok(apdu.includes('EA128010000000020101020200011603B0000107'), apdu); assert.ok(apdu.includes('EA13801100000002010102020002011603B0000107'), apdu);
}); });
test('SIM (CA) access domain FIRST, no services byte', () => { test('SIM (CA) access domain FIRST, no services byte', () => {
@@ -183,7 +183,7 @@ test('SIM (CA) access domain FIRST, no services byte', () => {
set('ram-tk-ad', '5A'); set('ram-tk-ad', '5A');
set('ram-tk-services', '0'); set('ram-tk-services', '0');
const apdu = genRamResult(); const apdu = genRamResult();
assert.ok(apdu.includes('CA11015A000000020101020200011603B00001'), apdu); assert.ok(apdu.includes('EF14CA12015A00000002010102020002011603B00001'), apdu);
}); });
test('SIM (CA) blank access domain emits length byte 00', () => { test('SIM (CA) blank access domain emits length byte 00', () => {
@@ -207,7 +207,7 @@ test('SIM (CA) blank access domain emits length byte 00', () => {
set('ram-tk-ad', ''); set('ram-tk-ad', '');
set('ram-tk-services', '0'); set('ram-tk-services', '0');
const apdu = genRamResult(); const apdu = genRamResult();
assert.ok(apdu.includes('CA1000000000020101020200011603B00001'), apdu); assert.ok(apdu.includes('EF13CA110000000002010102020002011603B00001'), apdu);
}); });
test('SIM (CA) m=3, no TAR, blank access domain', () => { test('SIM (CA) m=3, no TAR, blank access domain', () => {
@@ -231,7 +231,7 @@ test('SIM (CA) m=3, no TAR, blank access domain', () => {
set('ram-tk-ad', ''); set('ram-tk-ad', '');
set('ram-tk-services', '0'); set('ram-tk-services', '0');
const apdu = genRamResult(); const apdu = genRamResult();
assert.ok(apdu.includes('CA0F000000000301010000030300011600'), apdu); assert.ok(apdu.includes('EF12CA1000000000030101000003030002011600'), apdu);
}); });
test('UICC m=60 uses long-form BER lengths (EA 81 87 / inner 81 84)', () => { test('UICC m=60 uses long-form BER lengths (EA 81 87 / inner 81 84)', () => {
@@ -255,7 +255,7 @@ test('UICC m=60 uses long-form BER lengths (EA 81 87 / inner 81 84)', () => {
set('ram-tk-ad', ''); set('ram-tk-ad', '');
set('ram-tk-services', '0'); set('ram-tk-services', '0');
const apdu = genRamResult(); const apdu = genRamResult();
assert.ok(apdu.includes('EA8187808184'), apdu); assert.ok(apdu.includes('EA8188808185'), apdu);
}); });
test('LOAD P1 fixed to 80 (last block)', () => { test('LOAD P1 fixed to 80 (last block)', () => {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "1.8.1" version = "1.9.1"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+1 -1
View File
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '1.8.1' VERSION = '1.9.1'
# Static file serving (the PWA lives in <repo>/frontend, served by this server # Static file serving (the PWA lives in <repo>/frontend, served by this server