cc2edb3d8a
A card usually holds several OTA key sets, and the SCP80 counter is tracked
per key version on the card, so a preset now carries one keyset per
GlobalPlatform key version - the b8..b5 nibble of KIc/KID (TS 102 225
5.1.2/A.2), the version is implicit in the bytes. The spec is explicit:
"a dedicated counter shall be associated to each key version" (Annex A.1) and
"the key version number indicated in the KIc and KID (bits b8..b5) fields
shall be identical when different from 0" - a mismatch is rejected by the card
with "Unidentified security error" (A.2).
Server (presets.py, schema 2):
- a preset carries `keysets: [{kic, kid, kicKey, kidKey, cntr}]`; validation
refuses a KIc/KID version mismatch (A.2), a version outside 01-0F ('00'
means "no security" and is a packet-level choice, not a keyset), duplicate
versions and missing keys/counters; a v3.8.0 flat preset converts into one
keyset on load and on import;
- `set_counter(..., kvn)` writes the counter of that keyset (monotonic per
keyset), the audit line carries the kvn, and `find_keyset()` serves the
lookups;
- the operations derive the key version from the request's KIc byte and
persist into that keyset: /api/send-ota, the RAM install/install-app/compat
steps (_ram_send_gp_apdu) and the test runner's SCP80 steps (which may name
a `kvn` per step and keep one working counter per keyset);
- a non-zero key version the named preset does not define, or a KIc/KID
mismatch, is refused before anything is built (_preset_keyset_check /
_keyset_guard: 400, or a step error in the test runner). Without a
preset_id only the mismatch rule applies.
PWA:
- the Cards form's KIc/KID/counter/keys inputs become a keyset editor (one row
per version: KIc, KID, both keys, that version's counter, Add/Remove; the
version is shown derived from the bytes and a mismatch is flagged);
- the SCP80 and RAM views get a Key-version selector beside the preset
selector, driving the KIc/KID bytes and loading that keyset's keys and
counter; the packet builder refuses a version the preset does not define
(spKeysetCheck/spKeysetGuard, pure and unit-tested);
- the counter syncs are per keyset (spCntrLocalSync/ramSaveCntr), the SCP80
form's counter edit posts the keysets, and the test-run write-back syncs
every counter the server reports (scp80_counters).
Docs/help/AGENTS updated; sw cache simple-v335. Tests: 610 Python (store
keysets, endpoint refusals, the counter landing in the right keyset) and 726
Node (keysets.test.js plus the updated harnesses).
143 lines
6.1 KiB
JavaScript
143 lines
6.1 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('(?:async\\s+)?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);
|
|
}
|
|
|
|
// The real functions behind the Explore Delete flow: the counter the card
|
|
// consumed must be persisted into the preset, a successful delete drops the
|
|
// record locally (no re-explore), and a delete with no PoR (the SPI may
|
|
// request none) is not a failure - live 2026-09-28 showed "Failed: 9000"
|
|
// although the delete executed.
|
|
let code = '';
|
|
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
|
|
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
|
|
'ramHideProgress', 'ramDeleteFromExplorer',
|
|
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
|
|
'escHtml', 'esc',
|
|
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
|
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
|
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
|
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
|
|
'ramKeysetGuard',
|
|
]) {
|
|
code += extractFunc(html, fn) + '\n';
|
|
}
|
|
code = 'var _cardsKeysetCount = 0;\n' + code;
|
|
eval(code);
|
|
|
|
function fakeEnv(por, success) {
|
|
const els = {};
|
|
const mk = () => ({ value: '', textContent: '',
|
|
classList: { add() {}, remove() {}, toggle() {} } });
|
|
for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
|
|
'sp-cntr', 'sp-kic-key', 'sp-kid-key', 'ram-result', 'ram-steps',
|
|
'ram-progress', 'ram-progress-text']) els[id] = mk();
|
|
els['ram-card-sel'] = { value: '0' };
|
|
// the preset keys the real spRefreshFromPreset/cardsApply would copy into
|
|
// the form (the delete flow aborts without them)
|
|
els['sp-kic-key'].value = 'AA';
|
|
els['sp-kid-key'].value = 'BB';
|
|
els['sp-spi2-hex'].value = '01';
|
|
els['sp-kic-hex'].value = '15';
|
|
els['sp-kid-hex'].value = '15';
|
|
globalThis.document = { getElementById: id => els[id] || null };
|
|
globalThis.cards = [{ id: 'preset-1', name: 'C', keysets: [
|
|
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000005' }] }];
|
|
const calls = { saved: 0, explored: null, removed: null, sent: null };
|
|
globalThis.cardsSave = () => { calls.saved++; };
|
|
globalThis.cardsRender = () => {};
|
|
globalThis.ramRender = () => {};
|
|
globalThis.t = s => s;
|
|
globalThis.alert = () => {};
|
|
globalThis.confirm = () => true;
|
|
globalThis.spRefreshFromPreset = () => {
|
|
const cntr = cards[0].keysets[0].cntr;
|
|
els['sp-cntr'].value = cntr;
|
|
return cntr;
|
|
};
|
|
globalThis.ramSendOta = async (apdu, sp) => {
|
|
calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2, preset_id: sp.preset_id };
|
|
return { success: success !== false, por: por };
|
|
};
|
|
globalThis.ramExplore = async sp => { calls.explored = sp.cntr; };
|
|
globalThis.ramRemoveFromExplorer = (aid, cascade) => {
|
|
calls.removed = { aid: aid, cascade: cascade };
|
|
return true;
|
|
};
|
|
return { els, calls };
|
|
}
|
|
|
|
test('accepted delete persists the consumed counter and drops the record', async () => {
|
|
const { els, calls } = fakeEnv({ response_status: 'por_ok',
|
|
decoded: { last_status_word: '9000' } });
|
|
await ramDeleteFromExplorer('F0414C46416101', false);
|
|
assert.strictEqual(calls.sent.cntr, '0000000005');
|
|
assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C46416101');
|
|
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
|
|
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006',
|
|
'the preset must carry the counter the card consumed');
|
|
assert.strictEqual(els['sp-cntr'].value, '0000000006');
|
|
assert.strictEqual(calls.sent.preset_id, 'preset-1',
|
|
'the operation must name the server-side preset that persists the counter');
|
|
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
|
assert.strictEqual(calls.explored, null, 'no re-explore: the record is dropped locally');
|
|
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
|
});
|
|
|
|
test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => {
|
|
const { els, calls } = fakeEnv(undefined);
|
|
await ramDeleteFromExplorer('F0414C46416101', false);
|
|
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
|
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
|
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
|
assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent);
|
|
});
|
|
|
|
test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
|
|
const { calls } = fakeEnv({ response_status: 'cntr_low' });
|
|
await ramDeleteFromExplorer('F0414C46416101', false);
|
|
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
|
|
assert.strictEqual(calls.saved, 0);
|
|
assert.strictEqual(calls.removed, null);
|
|
});
|
|
|
|
test('a refused DELETE still advances the counter but drops nothing', async () => {
|
|
const { calls } = fakeEnv({ response_status: 'por_ok',
|
|
decoded: { last_status_word: '6A88' } });
|
|
await ramDeleteFromExplorer('F0414C46416101', true);
|
|
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
|
assert.strictEqual(calls.removed, null);
|
|
});
|
|
|
|
test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => {
|
|
const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
|
|
await ramDeleteFromExplorer('F0414C46416101', false);
|
|
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
|
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
|
});
|
|
|
|
test('a send failure leaves the preset untouched', async () => {
|
|
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
|
|
await ramDeleteFromExplorer('F0414C46416101', false);
|
|
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
|
|
assert.strictEqual(calls.removed, null);
|
|
});
|