Files
simtrace-analyser/frontend/tests/scp80_decrypt.test.js
T
catarrh 6d7e87b52a Add node:test suite for vendored C-APDU parser and SCP80 decryption
frontend/tests/ (otaman-style function extraction from index.html):
- apdu_parse.test.js: PARSE_INS registry, parseOneApdu (SELECT by FID,
  READ BINARY Le-form, UPDATE BINARY, GET DATA, lowercase input),
  parseCompactApdus (concatenated implied-CLA stream, graceful
  unrecognized-byte degradation).
- scp80_decrypt.test.js: decryptSecured known-answer tests with
  node-crypto generated ciphertexts — 3DES-CBC 2/3-key (real CNTR=200
  revealed), AES-CBC 128, single DES, wrong-key padding guard, key
  length validation, unsupported-algorithm rejection.
- helpers.js: brace-matched function/const/section extraction from
  the single-file frontend; package.json ("test": "node --test").

Fix AES-CBC decryption path found by the new tests: SubtleCrypto CBC
decrypt always validates PKCS#7, which SCP80 PCNTR-style padding
violates. Workaround: append a synthetic block C* = E(C_last XOR
padBlock) so the validator sees a full 0x10 padding block, decrypt,
then discard the synthetic plaintext. Validated: CNTR=200 case
decrypts correctly.

Version 1.20.1, SW cache simtrace-analyser-v29.
2026-08-24 22:34:23 +03:00

113 lines
4.6 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert');
const crypto = require('node:crypto');
const { loadSrc, extractFunc, extractSection } = require('./helpers.js');
const src = loadSrc();
// Assemble the client-side decryption machinery: DES/3DES core section,
// hex helpers, and the decryptSecured wrapper — all extracted verbatim
// from index.html so the tests exercise exactly what ships. Single
// direct eval: function declarations hoist into this module's scope.
const desSection = extractSection(src, '// ── DES / 3DES core', '// Decrypt a ciphered secured packet');
eval(
extractFunc(src, 'hexToBytes') + '\n' +
extractFunc(src, 'bytesToHex') + '\n' +
desSection + '\n' +
extractFunc(src, 'decryptSecured')
);
const b2h = b => Buffer.from(b).toString('hex').toUpperCase();
function encryptBlock(algo, key, iv, plaintext) {
// node/OpenSSL 3 has no single DES — encrypt the reference via
// des-ede3-cbc with K1=K2=K3 (≡ single DES). node's des-ede3-cbc
// also requires a 24-byte key — expand 2-key (16B) to K1,K2,K1.
let k;
if (algo === 'des-cbc') k = Buffer.concat([key, key, key]);
else if (algo === 'des-ede3-cbc' && key.length === 16) k = Buffer.concat([key, key.slice(0, 8)]);
else k = key;
const c = crypto.createCipheriv(algo === 'des-cbc' ? 'des-ede3-cbc' : algo, k, iv);
c.setAutoPadding(false); // SCP80 pads via PCNTR, not PKCS
return Buffer.concat([c.update(plaintext), c.final()]);
}
// Build a fake secured-packet decode: cipher_block = 3DES/AES-CBC
// ciphertext over [CNTR(5) ‖ PCNTR(1) ‖ RC(8) ‖ data ‖ padding].
function makeSecured(algo, keyHex, cntrInt, dataHex) {
const key = Buffer.from(keyHex, 'hex');
const rc = Buffer.from('1122334455667788', 'hex');
const data = Buffer.from(dataHex, 'hex');
const block = algo === 'aes-128-cbc' ? 16 : 8;
const iv = Buffer.alloc(block);
const bodyLen = 5 + 1 + rc.length + data.length;
const pad = block - (bodyLen % block);
const pt = Buffer.concat([
Buffer.from(cntrInt.toString(16).padStart(10, '0'), 'hex'),
Buffer.from([pad]),
rc,
data,
Buffer.alloc(pad),
]);
const ct = encryptBlock(algo, key, iv, pt);
return {
kic_raw: algo === 'aes-128-cbc' ? 0x02 : algo === 'des-cbc' ? 0x01 : 0x15,
cipher_block: ct.toString('hex').toUpperCase(),
rc_cc_ds: rc.toString('hex').toUpperCase(),
};
}
test('decryptSecured: 3DES-CBC (2 keys) reveals real CNTR/PCNTR/RC/data', async () => {
const keyHex = '0123456789ABCDEFFEDCBA9876543210';
const sec = makeSecured('des-ede3-cbc', keyHex, 200, 'DEADBEEFCAFE');
const r = await decryptSecured(sec, keyHex);
assert.equal(b2h(r.cntr), '00000000C8'); // the real counter, 200
assert.equal(r.pcntr, 4); // padding octets
assert.equal(b2h(r.rc), '1122334455667788');
assert.equal(b2h(r.data), 'DEADBEEFCAFE');
});
test('decryptSecured: 3DES-CBC (3 keys, 24-byte key)', async () => {
const keyHex = '0123456789ABCDEFFEDCBA98765432100123456789ABCDEF';
const sec = makeSecured('des-ede3-cbc', keyHex, 7, 'CAFE');
const r = await decryptSecured(sec, keyHex);
assert.equal(parseInt(b2h(r.cntr), 16), 7);
assert.equal(b2h(r.data), 'CAFE');
});
test('decryptSecured: AES-CBC (128-bit key)', async () => {
const keyHex = '000102030405060708090A0B0C0D0E0F';
const sec = makeSecured('aes-128-cbc', keyHex, 1234, 'BEEF');
const r = await decryptSecured(sec, keyHex);
assert.equal(parseInt(b2h(r.cntr), 16), 1234);
assert.equal(b2h(r.data), 'BEEF');
});
test('decryptSecured: wrong key detected via padding guard', async () => {
const sec = makeSecured('des-ede3-cbc', '0123456789ABCDEFFEDCBA9876543210', 200, 'DEADBEEFCAFE');
await assert.rejects(
() => decryptSecured(sec, 'FFFFFFFFFEDCBA987654321001234567'),
/wrong key|invalid padding|too short/
);
});
test('decryptSecured: key length validation', async () => {
const sec = makeSecured('des-ede3-cbc', '0123456789ABCDEFFEDCBA9876543210', 200, 'DEADBEEFCAFE');
await assert.rejects(() => decryptSecured(sec, '0102'), /key must be/);
});
test('decryptSecured: unsupported algorithm nibble', async () => {
const sec = makeSecured('des-ede3-cbc', '0123456789ABCDEFFEDCBA9876543210', 200, 'DEADBEEFCAFE');
sec.kic_raw = 0x00; // 'implicit' — cannot pick a cipher
await assert.rejects(() => decryptSecured(sec, '0123456789ABCDEFFEDCBA9876543210'),
/not supported/);
});
test('decryptSecured: DES-CBC single key (deprecated KIc 01)', async () => {
const keyHex = '133457799BBCDFF1';
const sec = makeSecured('des-cbc', keyHex, 42, '0123');
const r = await decryptSecured(sec, keyHex);
assert.equal(parseInt(b2h(r.cntr), 16), 42);
assert.equal(b2h(r.data), '0123');
});