Files
meshcore-analyzer/tests/unit/test-channel-decrypt-ecb.js
Alex B 893773338e chore(tests): move root test-*.js into tests/unit and tests/e2e (#2036)
Moves 290 root test-*.js into tests/unit (177, listed in test-all.sh) and tests/e2e (113, classified in scripts/non-unit-tests.json), per #1981 and PR-D of #1385. Root goes from 348 entries to 48. test-all.sh and test-fixtures/ stay put. The inventory guard now fails if a test reappears in the root or sits in the wrong folder.

Verified independently of the diff: the invoked sets are unchanged (test-all.sh 177 before and after, deploy.yml 96 before and after, both identical as sets), and a full local run of test-all.sh on master and on the branch produced 4702 output lines each whose only differences are absolute paths, stack-trace line numbers shifted by the REPO_ROOT line, the inventory wording and two perf ratios. The guard was mutation-checked: a test back in the root, a unit suite in tests/e2e, and a suite dropped from test-all.sh each make it exit 1. CI run 35246304316 ran 97 suites from tests/e2e and is green.

Follow-up 9335c51d finished the instruction files: no bare root test command is left in AGENTS.md, the squad charters, .github or docs, and every tests/ path they name resolves.

Merged by the interim maintainer without a second human reviewer: CI and the local runs above are the independent checks.

Known and deliberately out of scope: 18 of the 113 files in tests/e2e are invoked by no runner at all, and one of them cannot run anywhere because it requires jsdom, which is not a declared dependency. Tracked separately.
2026-09-17 19:06:07 +02:00

114 lines
4.2 KiB
JavaScript

/**
* Tests for AES-128-ECB decryption in public/channel-decrypt.js.
*
* Background: the original implementation simulated ECB via Web Crypto
* AES-CBC with a zero IV and a dummy PKCS7 padding block. Web Crypto
* validates PKCS7 padding on the decrypted output and throws an
* `OperationError` whenever the last 16 bytes of the (CBC-decrypted)
* output don't form a valid PKCS7 padding sequence — which is the
* common case here, since the input is real ciphertext, not a padded
* second block. This test pins decryptECB() to the FIPS-197 NIST
* AES-128-ECB known-answer vector (Appendix B / C.1) so that the
* implementation cannot regress to any Web Crypto + ECB hack.
*
* Vector (FIPS-197 Appendix C.1, single-block AES-128 ECB):
* key = 000102030405060708090a0b0c0d0e0f
* plaintext = 00112233445566778899aabbccddeeff
* ciphertext = 69c4e0d86a7b0430d8cdb78070b4c55a
*/
'use strict';
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const { subtle } = require('crypto').webcrypto;
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function loadChannelDecrypt() {
const storage = {};
const localStorage = {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
};
const sandbox = {
window: {}, crypto: { subtle }, TextEncoder, TextDecoder, Uint8Array,
localStorage, console, Date, JSON, parseInt, Math, String, Number,
Object, Array, RegExp, Error, Promise, setTimeout,
};
sandbox.window = sandbox; sandbox.self = sandbox;
vm.createContext(sandbox);
// Load vendored AES (if present) before channel-decrypt.js.
const vendorPath = path.join(REPO_ROOT, 'public/vendor/aes-ecb.js');
if (fs.existsSync(vendorPath)) {
vm.runInContext(fs.readFileSync(vendorPath, 'utf8'), sandbox);
}
vm.runInContext(
fs.readFileSync(path.join(REPO_ROOT, 'public/channel-decrypt.js'), 'utf8'),
sandbox
);
return sandbox.window.ChannelDecrypt;
}
async function runTests() {
console.log('\n=== AES-128-ECB known-answer vector (FIPS-197 C.1) ===');
const CD = loadChannelDecrypt();
const key = CD.hexToBytes('000102030405060708090a0b0c0d0e0f');
const ct = CD.hexToBytes('69c4e0d86a7b0430d8cdb78070b4c55a');
const expectedPlaintextHex = '00112233445566778899aabbccddeeff';
let result, threw = null;
try {
result = await CD.decryptECB(key, ct);
} catch (e) {
threw = e;
}
assert(threw === null, 'decryptECB does not throw on valid ciphertext (got: ' + (threw && threw.message) + ')');
assert(result instanceof Uint8Array, 'decryptECB returns a Uint8Array');
assert(
result && CD.bytesToHex(result) === expectedPlaintextHex,
'decryptECB matches FIPS-197 vector (got ' + (result ? CD.bytesToHex(result) : 'null') + ')'
);
// Multi-block: two copies of the same block must produce two copies
// of the same plaintext (true ECB property — no chaining).
console.log('\n=== AES-128-ECB multi-block (no chaining) ===');
const ct2 = new Uint8Array(32);
ct2.set(ct, 0); ct2.set(ct, 16);
let result2, threw2 = null;
try { result2 = await CD.decryptECB(key, ct2); }
catch (e) { threw2 = e; }
assert(threw2 === null, 'decryptECB does not throw on 2-block ciphertext');
assert(
result2 &&
CD.bytesToHex(result2.slice(0, 16)) === expectedPlaintextHex &&
CD.bytesToHex(result2.slice(16, 32)) === expectedPlaintextHex,
'decryptECB on duplicated block yields duplicated plaintext (ECB, no chaining)'
);
// Empty / misaligned input must return null (existing contract).
console.log('\n=== Edge cases ===');
const empty = await CD.decryptECB(key, new Uint8Array(0));
assert(empty === null, 'empty ciphertext returns null');
const misaligned = await CD.decryptECB(key, new Uint8Array(15));
assert(misaligned === null, 'misaligned ciphertext returns null');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(e => { console.error(e); process.exit(1); });