mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 22:54:44 +00:00
cb21305dc4
## P0: channel decryption broken on prod (`OperationError` in
`decryptECB`)
### Symptom
```
Uncaught (in promise) OperationError
at decryptECB (channel-decrypt.js:89)
at async Object.decrypt (channel-decrypt.js:181)
at async decryptCandidates (channels.js:568)
```
Channel message decryption fails for most ciphertext blocks in the
browser console on `analyzer.00id.net`.
### Root cause
The original `decryptECB()` simulated AES-128-ECB via Web Crypto AES-CBC
with a zero IV plus an appended dummy PKCS7 padding block (16 × `0x10`).
Web Crypto **always** validates PKCS7 padding on the decrypted output,
and after CBC-decrypting the dummy padding block it almost never
produces a valid PKCS7 sequence, so Chrome/Firefox throw
`OperationError`. There is no Web Crypto knob to disable that check —
and Web Crypto doesn't expose raw ECB at all.
This is a well-known dead end: every project that needs ECB in browsers
ends up with a small pure-JS AES core.
### Fix
- Vendor a minimal pure-JS **AES-128 ECB decrypt-only** core into
`public/vendor/aes-ecb.js`.
- **Source:** [aes-js](https://github.com/ricmoo/aes-js) by Richard
Moore — MIT License (cited in the header comment).
- **Trimmed to:** S-boxes, key expansion (FIPS-197 §5.2), inverse cipher
(FIPS-197 §5.3). No encrypt path. No other modes. No padding logic. ~150
lines.
- `decryptECB(key, ciphertext)` keeps the same API surface:
`Promise<Uint8Array | null>`. It now delegates to
`window.AES_ECB.decrypt(...)`.
- `verifyMAC` and `computeChannelHash` keep using Web Crypto
(HMAC-SHA256 / SHA-256 — no padding pathology).
- Wired `vendor/aes-ecb.js` into `public/index.html` immediately before
`channel-decrypt.js`.
### TDD
- **Red commit (`36f6882`)** — adds `test-channel-decrypt-ecb.js` pinned
to the **FIPS-197 Appendix C.1** AES-128 known-answer vector. Compiles,
runs, and fails on assertion (`OperationError`) against the existing
implementation.
- **Green commit (`bbbd2d1`)** — vendors the pure-JS AES core and
rewires `decryptECB`. Test now passes (7/7), including a multi-block
assertion that two identical ciphertext blocks decrypt to two identical
plaintext blocks (true ECB, no chaining).
- Existing `test-channel-decrypt-m345.js` still passes (24/24).
### Files changed
- `public/vendor/aes-ecb.js` — **new** (vendored AES-128 ECB decrypt,
MIT, ~150 LOC)
- `public/channel-decrypt.js` — `decryptECB()` rewritten to delegate to
vendor
- `public/index.html` — script tag added for `vendor/aes-ecb.js`
- `test-channel-decrypt-ecb.js` — **new** TDD test (FIPS-197 KAT +
multi-block + edge cases)
### Risk / scope
- Decrypt-only, client-side, no server changes, no schema changes, no
config changes (Config Documentation Rule N/A).
- ECB is a single 16-byte block per packet for MeshCore channel traffic,
so the perf delta vs Web Crypto is negligible (a single `decryptBlock`
is ~10 round transforms on 16 bytes).
- HTTP-context safe (no Web Crypto required for ECB anymore).
### Validation
- All 7 FIPS-197 KAT + multi-block tests pass.
- Existing channel-decrypt M3/M4/M5 tests still pass (24/24).
- `test-packet-filter.js` (62/62), `test-aging.js` (18/18) unaffected.
- `test-frontend-helpers.js` has a pre-existing failure on master
unrelated to this PR (verified by stashing the patch).
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
113 lines
4.2 KiB
JavaScript
113 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 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(__dirname, 'public/vendor/aes-ecb.js');
|
|
if (fs.existsSync(vendorPath)) {
|
|
vm.runInContext(fs.readFileSync(vendorPath, 'utf8'), sandbox);
|
|
}
|
|
vm.runInContext(
|
|
fs.readFileSync(path.join(__dirname, '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); });
|