scp81: PSK TLS server and command scripting (phases B/C) + BIP fix (v2.1.5)
- scp81.py: PSK TLS listener (stdlib ssl PSK callbacks) speaking the GP HTTP administration dialog; configurable framing (chunked/Content-Length, TLS record split, Apache-style/compact headers, Connection header, keep-alive, Next-URI template with %d, TLS version/cipher, answer delay, keylog for capture decryption) - server.py: script responder + Response Scripting parsing (AF/AB, 80/23 TLVs), memory decoder, SCP81 start options, terminal-side timer management, background-mode BIP events, permissive OPEN CHANNEL - BIP fix: the RECEIVE DATA channel-data TLV length is BER long form (36 81 <len>) above 127 bytes; a raw length byte is mis-parsed on the card, so the large TLS records never reached its stack (a live card fetched the script response and silently never processed it - endless resume). The card now executes scripts and returns R-APDUs: memory (13 applets, 50646 B NV free, 2402 B volatile), ISD, stored HTTP OTA parameters, ELF and application registries - frontend: SCP81 tab (listener, script selection, HTTP OTA log), phone event forms, i18n; service worker v141 - docs: api.md, scp81-findings.md (attempt matrix + root cause analysis); tools/scp81_decrypt.py decrypts listener captures via the keylog - tests: 187 python + 337 frontend
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
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 extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found');
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
|
||||
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'cmdQualifierShort'));
|
||||
eval(extractBlock('const REJECTION_CAUSES = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
|
||||
|
||||
test('CMD_NAMES decodes timer management and the BIP commands', () => {
|
||||
assert.strictEqual(CMD_NAMES['27'], 'TIMER MANAGEMENT');
|
||||
assert.strictEqual(CMD_NAMES['40'], 'OPEN CHANNEL');
|
||||
assert.strictEqual(CMD_NAMES['41'], 'CLOSE CHANNEL');
|
||||
assert.strictEqual(CMD_NAMES['42'], 'RECEIVE DATA');
|
||||
assert.strictEqual(CMD_NAMES['43'], 'SEND DATA');
|
||||
assert.strictEqual(CMD_NAMES['44'], 'GET CHANNEL STATUS');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort decodes TIMER MANAGEMENT actions', () => {
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x00), 'Start');
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x01), 'Deactivate');
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x02), 'Get');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort decodes OPEN CHANNEL qualifier flags', () => {
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x00), 'OnDemand');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x01), 'Immediate');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x03), 'Immediate+AutoReconn');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x05), 'Background');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x0C), 'Background+DNS');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort returns empty for unknown types', () => {
|
||||
assert.strictEqual(cmdQualifierShort('99', 0x01), '');
|
||||
});
|
||||
|
||||
test('channel status event builds the B8 channel status TLV', () => {
|
||||
const build = EVENT_FORMS[0x0A].build;
|
||||
assert.strictEqual(EVENT_FORMS[0x0A].note, undefined);
|
||||
assert.strictEqual(build({ channel: '2', state: '128', info: '5' }), 'B8028205');
|
||||
assert.strictEqual(build({ channel: '1', state: '0', info: '0' }), 'B8020100');
|
||||
assert.strictEqual(build({ channel: '0', state: '64', info: '0' }), 'B8024000');
|
||||
});
|
||||
@@ -13,7 +13,7 @@ test('HTML <div> tags are balanced', () => {
|
||||
|
||||
test('top-level tabs match the rearranged views', () => {
|
||||
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'profiler', 'pysim', 'phone']);
|
||||
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', 'profiler', 'pysim', 'phone']);
|
||||
assert.match(html, /data-tab="c-apdu">Remote APDU</);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
}
|
||||
|
||||
eval(extractFunc(html, 'scp81LogLine'));
|
||||
|
||||
test('scp81LogLine renders a BIP open entry', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 4, kind: 'open', channel: 1, requested: '77.221.153.19:10174', target: '127.0.0.1:8443' }),
|
||||
'4 open ch1 77.221.153.19:10174 -> 127.0.0.1:8443');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders a TLS request with the GP headers', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 5, kind: 'tls-request', method: 'POST', uri: '/server/adminagent?cmd=1', agent: '0123456789', bytes: 0 }),
|
||||
'5 tls-request from=0123456789 POST /server/adminagent?cmd=1');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders handshake and errors', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 6, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256', identity: 'id-1' }),
|
||||
'6 tls-handshake id=id-1 PSK-AES128-CBC-SHA256');
|
||||
assert.strictEqual(scp81LogLine({ seq: 7, kind: 'tls-error', error: 'boom' }), '7 tls-error boom');
|
||||
});
|
||||
|
||||
test('scp81 log covers the dump mode kinds', () => {
|
||||
assert.strictEqual(scp81LogLine({ seq: 1, kind: 'dump-rx', bytes: 71 }), '1 dump-rx 71B');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders script entries', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 9, kind: 'script-send', index: 1, apdu: '80CAFF2100' }),
|
||||
'9 script-send #1 80CAFF2100');
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 12, kind: 'script-rapdu', index: 1, sw: '9000', bytes: 14 }),
|
||||
'12 script-rapdu #1 SW 9000 14B');
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 13, kind: 'script-memory', applets: 4, free_nv: 61600, free_volatile: 2048 }),
|
||||
'13 script-memory applets=4 free NV=61600 free vol=2048');
|
||||
});
|
||||
Reference in New Issue
Block a user