Files
otaman/tools/scp81_decrypt.py
T
catarrh 175ca934d8 scp81: PSK TLS server and command scripting (phases B/C) + BIP fix (v2.1.5)
- scp81.py: PSK TLS listener (stdlib ssl PSK callbacks) speaking the GP
  HTTP administration dialog; configurable framing (chunked/Content-Length,
  TLS record split, Apache-style/compact headers, Connection header,
  keep-alive, Next-URI template with %d, TLS version/cipher, answer delay,
  keylog for capture decryption)
- server.py: script responder + Response Scripting parsing (AF/AB, 80/23
  TLVs), memory decoder, SCP81 start options, terminal-side timer
  management, background-mode BIP events, permissive OPEN CHANNEL
- BIP fix: the RECEIVE DATA channel-data TLV length is BER long form
  (36 81 <len>) above 127 bytes; a raw length byte is mis-parsed on the
  card, so the large TLS records never reached its stack (a live card
  fetched the script response and silently never processed it - endless
  resume). The card now executes scripts and returns R-APDUs: memory
  (13 applets, 50646 B NV free, 2402 B volatile), ISD, stored HTTP OTA
  parameters, ELF and application registries
- frontend: SCP81 tab (listener, script selection, HTTP OTA log), phone
  event forms, i18n; service worker v141
- docs: api.md, scp81-findings.md (attempt matrix + root cause analysis);
  tools/scp81_decrypt.py decrypts listener captures via the keylog
- tests: 187 python + 337 frontend
2026-09-16 01:50:29 +03:00

136 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Decrypt the SCP81 PSK-TLS dialog from a server log + keylog.
The server's /api/scp81/log has every TLS record of the dialog (send = card,
receive = card fetch, i.e. the server stream), and the listener can write the
TLS secrets (SSLKEYLOGFILE) when started with a "keylog" path. With the
PSK-AES128-CBC-SHA256 dialog we can derive the record keys (TLS 1.2
PRF/master secret) and decrypt the card's alerts, which are otherwise opaque.
Usage: scp81_decrypt.py <log.json> <keys.log>
"""
import hashlib
import hmac
import json
import subprocess
import sys
def p_sha256(secret, seed, length):
out = b''
a = seed
while len(out) < length:
a = hmac.new(secret, a, hashlib.sha256).digest()
out += hmac.new(secret, a + seed, hashlib.sha256).digest()
return out[:length]
def aes_cbc_decrypt(key, iv, data):
p = subprocess.run(['openssl', 'enc', '-d', '-aes-128-cbc', '-nopad',
'-K', key.hex(), '-iv', iv.hex()],
input=data, capture_output=True)
if p.returncode != 0:
raise RuntimeError(p.stderr.decode())
return p.stdout
def record_payloads(stream):
"""Walk TLS records in a byte stream, yield (type, version, payload)."""
i = 0
while i + 5 <= len(stream):
rtype, ver, ln = stream[i], stream[i + 1:i + 3], int.from_bytes(stream[i + 3:i + 5], 'big')
body = stream[i + 5:i + 5 + ln]
if len(body) < ln:
break
yield rtype, ver, body
i += 5 + ln
def find_random(stream, hs_type):
"""Return the 32-byte random of a ClientHello/ServerHello in the stream."""
for rtype, ver, body in record_payloads(stream):
if rtype != 0x16 or not body or body[0] != hs_type:
continue
hslen = int.from_bytes(body[1:4], 'big')
hs = body[:4 + hslen]
return hs[6:38]
return None
def main():
log_path, keys_path = sys.argv[1], sys.argv[2]
entries = sorted(json.load(open(log_path))['entries'], key=lambda x: x['seq'])
# Only the last TLS session: start at the final OPEN CHANNEL.
start = 0
for i, e in enumerate(entries):
if e.get('kind') == 'open':
start = i
entries = entries[start:]
client = b''
server = b''
for e in entries:
if e.get('kind') == 'send' and e.get('hex'):
client += bytes.fromhex(e['hex'])
elif e.get('kind') == 'receive' and e.get('hex'):
server += bytes.fromhex(e['hex'])
crandom = find_random(client, 0x01)
srandom = find_random(server, 0x02)
print('client_random:', crandom.hex() if crandom else None)
print('server_random:', srandom.hex() if srandom else None)
if not crandom or not srandom:
sys.exit('handshake randoms not found in log')
master = None
for line in open(keys_path):
parts = line.split()
if parts and parts[0] == 'CLIENT_RANDOM' and parts[1] == crandom.hex():
master = bytes.fromhex(parts[2])
if not master:
sys.exit('master secret not found in keylog')
print('master_secret:', master.hex())
kb = p_sha256(master, b'key expansion' + srandom + crandom, 96)
client_mac, server_mac = kb[0:32], kb[32:64]
client_key, server_key = kb[64:80], kb[80:96]
print('client_key: %s server_key: %s' % (client_key.hex(), server_key.hex()))
names = {0x15: 'alert', 0x16: 'handshake', 0x17: 'appdata', 0x14: 'ccs'}
for who, stream, key in (('card', client, client_key),
('server', server, server_key)):
app_seq = 0
for rtype, ver, body in record_payloads(stream):
if rtype not in (0x15, 0x17) or len(body) < 16 + 32:
continue
iv, ct, mac = body[:16], body[16:-32], body[-32:]
try:
pt = aes_cbc_decrypt(key, iv, ct)
except RuntimeError as e:
print('%s seq%d %s: decrypt failed: %s' % (who, app_seq, names.get(rtype), e))
app_seq += 1
continue
# verify the record MAC (seq, type, version, len, plaintext)
h = hmac.new(client_mac if who == 'card' else server_mac,
app_seq.to_bytes(8, 'big') + bytes([rtype]) + ver +
len(pt).to_bytes(2, 'big') + pt, hashlib.sha256).digest()
mac_ok = hmac.compare_digest(h, mac)
desc = ''
if rtype == 0x15 and len(pt) >= 2:
level = {1: 'warning', 2: 'fatal'}.get(pt[0], str(pt[0]))
alerts = {0: 'close_notify', 10: 'unexpected_message',
20: 'bad_record_mac', 40: 'handshake_failure',
46: 'protocol_version', 47: 'illegal_parameter',
48: 'unknown_ca', 49: 'access_denied',
50: 'decode_error', 51: 'decrypt_error',
80: 'internal_error', 90: 'user_canceled',
100: 'no_renegotiation', 110: 'unsupported_extension',
112: 'unrecognized_name'}
desc = 'ALERT %s %s' % (level, alerts.get(pt[1], pt[1]))
print('%s seq%d %-9s mac_ok=%s pt=%s %s'
% (who, app_seq, names.get(rtype), mac_ok, pt[:48].hex(), desc))
app_seq += 1
if __name__ == '__main__':
main()