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
This commit is contained in:
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user