Client-side SCP80 decryption: pure-JS 3DES/AES with user-supplied keys

Adds optional decryption of ciphered secured packets, entirely in the
browser — the key never leaves the frontend.

- decode.py: _decode_secured_packet exposes 'cipher_block' (the
  contiguous on-wire ciphered octets CNTR..end, ready for CBC) and
  raw kic/kid bytes for algorithm selection. Additive only.
- index.html: pure-JS DES/3DES core (FIPS 46-3 bit-array tables,
  parity ignored) + CBC; AES-CBC via native SubtleCrypto; both with
  zero IV per TS 102 225. decryptSecured() picks the algorithm from
  the KIc nibble (02 AES-CBC, 05 3DES-2K, 09 3DES-3K, 01 DES) with
  key-length validation.
- UI: ciphered packets show a key-hex input + Decrypt strip below the
  detail table; keys are memoized per key-set for the session in an
  in-memory OTA_KEYS map — never sent to the server, never persisted.
- Decrypted results cached by cipher_block (survive live-polling
  re-renders); plaintext rows rendered as CNTR/PCNTR/RC/Secured data
  (decrypted) alongside the ciphertext; wrong-key guard via the
  decrypted padding counter (PCNTR > remaining → "invalid padding").
- 3DES implementation validated byte-identical against openssl
  des-ede3-cbc (3-key, 2-key ×3 random vectors, single-key).

No CC verification (out of scope). Tests: +2 (cipher_block
contiguity, AES registry guard) — 350 total. Version 1.19.0,
SW cache simtrace-analyser-v27.
This commit is contained in:
catarrh committed 2026-08-24 21:12:24 +03:00
1 parent 840e233baf
commit d21997764f
7 files changed
+253 -5

No files matched your search

+22
View File
@@ -2127,6 +2127,28 @@ class TestSecuredCiphering(unittest.TestCase):
r = decode_message(raw, prev=prev)
self.assertTrue(r['response']['ciphered'])
def test_cipher_block_contiguous(self):
# cipher_block = the on-wire ciphered octets (CNTR..end) and equals
# the reassembled cntr+pcntr+rc+data — what a client-side decryptor
# feeds to 3DES/AES-CBC.
from simtrace2_pysniff.server.decode import _decode_secured_packet
sec = bytes.fromhex('00381516011515b000011830a44e4191ad45d0ec'
'2a291315986468a539d248fcdce7952c31cd635baec'
'7e93a5d567b59f6283316c0dad8603bf3'.replace(' ', ''))
r = _decode_secured_packet(sec)
self.assertEqual(len(r['cipher_block']) // 2, 48)
self.assertEqual(r['cipher_block'],
(r['cntr'] + f'{r["pcntr"]:02X}' + r['rc_cc_ds'] + r['data']))
self.assertEqual(r['kic_raw'], 0x15)
self.assertEqual(r['kid_raw'], 0x15)
def test_aes_algorithm_recognized(self):
# AES must stay recognized for ciphering (KIc nibble 2) and for the
# CC (KID nibble 2 = AES-CMAC) — the client-side decryptor relies on it.
from simtrace2_pysniff.server.decode import _KIC_ALGO, _KID_CC_ALGO
self.assertEqual(_KIC_ALGO[0x02], 'AES-CBC')
self.assertEqual(_KID_CC_ALGO[0x02], 'AES-CMAC')
class TestSpiBits(unittest.TestCase):
"""TS 102 225 §5.1.1: SPI1 b3 = ciphering, SPI2 b5 = PoR ciphering."""
+1 -1
View File
@@ -197,7 +197,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase):
def test_package_version(self):
from simtrace2_pysniff import __version__
self.assertEqual(__version__, '1.18.0')
self.assertEqual(__version__, '1.19.0')
def test_status_includes_version(self):
import os