#!/usr/bin/env python3 """Decrypt and decode PSP ad hoc Wi-Fi traffic from a radiotap pcap capture. A real PSP encrypts its ad hoc link traffic, so a sniffed capture of two consoles playing is ciphertext. This tool decrypts it and decodes the PDP/PTP/matching framing, which is handy for comparing what a game does on hardware against what PPSSPP sends at the sceNetAdhoc HLE level. adhoc_decrypt.py CAPTURE.pcap # handshake + list the data frames adhoc_decrypt.py CAPTURE.pcap --key HEX # decrypt (16-byte key) or decrypt+verify (20-byte) adhoc_decrypt.py CAPTURE.pcap --known HEX16@CTR --known HEX16@CTR # recover the key, then decrypt adhoc_decrypt.py --selftest The key is ephemeral (negotiated per ad hoc group membership by an ECDH handshake), so it is not in the capture. But the data cipher has a known-plaintext weakness: with one known 16-byte plaintext block for an even-counter frame and one for an odd-counter frame, --known recovers the 16-byte key that decrypts everything (one AES op each, validated by the frame's own counter). The last 4 bytes of the 20-byte key only feed the integrity tag; recover them with adhoc_recover.c if you want tags verified. This is not a break of the key exchange - it just needs a capture that contains some known plaintext (the ad hoc envelope's constant fields plus, e.g., a game's constant packet header or a nickname you set). Capturing needs a Wi-Fi adapter in monitor mode on the consoles' ad hoc channel. The two fixed KIRK keys below ship in PPSSPP already (ext/libkirk/kirk_engine.c). Pure Python. """ import argparse import hashlib import struct import sys import zlib # ---- AES-128, pure Python ------------------------------------------------------------------ def _xt(a): a <<= 1 return (a ^ 0x11B) if a & 0x100 else a def _mul(a, b): r = 0 while b: if b & 1: r ^= a a = _xt(a) b >>= 1 return r SBOX = [0] * 256 INV = [0] * 256 def _init_sbox(): for i in range(256): inv = 0 if i: for j in range(1, 256): if _mul(i, j) == 1: inv = j break x = inv s = x ^ ((x << 1 | x >> 7) & 0xFF) ^ ((x << 2 | x >> 6) & 0xFF) ^ ((x << 3 | x >> 5) & 0xFF) ^ ((x << 4 | x >> 4) & 0xFF) ^ 0x63 SBOX[i] = s INV[s] = i _init_sbox() def aes_expand(key): w = [list(key[i:i + 4]) for i in range(0, 16, 4)] rc = 1 for i in range(4, 44): t = list(w[i - 1]) if i % 4 == 0: t = [SBOX[b] for b in t[1:] + t[:1]] t[0] ^= rc rc = _xt(rc) w.append([w[i - 4][j] ^ t[j] for j in range(4)]) return [sum(w[r * 4:r * 4 + 4], []) for r in range(11)] def aes_enc(rk, b): s = [b[i] ^ rk[0][i] for i in range(16)] for r in range(1, 11): s = [SBOX[x] for x in s] s = [s[(i + 4 * (i % 4)) % 16] for i in range(16)] if r != 10: n = [] for c in range(4): a = s[c * 4:c * 4 + 4] n += [_mul(a[0], 2) ^ _mul(a[1], 3) ^ a[2] ^ a[3], a[0] ^ _mul(a[1], 2) ^ _mul(a[2], 3) ^ a[3], a[0] ^ a[1] ^ _mul(a[2], 2) ^ _mul(a[3], 3), _mul(a[0], 3) ^ a[1] ^ a[2] ^ _mul(a[3], 2)] s = n s = [s[i] ^ rk[r][i] for i in range(16)] return bytes(s) def aes_dec(rk, b): s = [b[i] ^ rk[10][i] for i in range(16)] for r in range(9, -1, -1): s = [s[(i - 4 * (i % 4)) % 16] for i in range(16)] s = [INV[x] for x in s] s = [s[i] ^ rk[r][i] for i in range(16)] if r: n = [] for c in range(4): a = s[c * 4:c * 4 + 4] n += [_mul(a[0], 14) ^ _mul(a[1], 11) ^ _mul(a[2], 13) ^ _mul(a[3], 9), _mul(a[0], 9) ^ _mul(a[1], 14) ^ _mul(a[2], 11) ^ _mul(a[3], 13), _mul(a[0], 13) ^ _mul(a[1], 9) ^ _mul(a[2], 14) ^ _mul(a[3], 11), _mul(a[0], 11) ^ _mul(a[1], 13) ^ _mul(a[2], 9) ^ _mul(a[3], 14)] s = n return bytes(s) def xor(a, b): return bytes(x ^ y for x, y in zip(a, b)) def kirk7(key, data): """KIRK command 7: AES-128-CBC decrypt, IV 0.""" rk = aes_expand(key) out, prev = b'', bytes(16) for i in range(0, len(data) - len(data) % 16, 16): c = data[i:i + 16] out += xor(aes_dec(rk, c), prev) prev = c return out # Fixed KIRK 4/7 keys (already in ext/libkirk/kirk_engine.c keyvault). KIRK_KEY_02 = bytes.fromhex('B813C35EC64441E3DC3C16F5B45E6484') # auth handshake messages KIRK_KEY_44 = bytes.fromhex('7DF49265E3FAD678D6FE78ADBB3DFB63') # data keystream # The handshake's ECDH curve (same as KIRK's ec_p/ec_a/ec_b). EC_P = 0xFFFFFFFFFFFFFFFF00000001FFFFFFFFFFFFFFFF EC_A = EC_P - 3 EC_B = 0xA68BEDC33418029C1D3CE33B9A321FCCBB9E0F0B def on_curve(pt): x = int.from_bytes(pt[:20], 'big') y = int.from_bytes(pt[20:40], 'big') return (y * y - (x * x * x + EC_A * x + EC_B)) % EC_P == 0 # ---- data cipher --------------------------------------------------------------------------- # # A data frame is: 00 02 | counter(8, LE) | tag(16) | ciphertext. # The counter counts 16-byte blocks sent in that direction, cumulative, from 0. # Keystream: KIRK-7 (AES-CBC, IV 0, key 0x44) of consecutive blocks [C||half, C+1||half', ...], # where half = key[0:8] on even block counters and key[8:16] on odd. Ciphertext = plaintext XOR it. # Tag: first 16 bytes of SHA1(key20 || counter_header(16) || ciphertext). def keystream(key16, counter, nblocks): blocks = b'' for i in range(nblocks): c = counter + i blocks += struct.pack('H', pt[2:4])[0] sp, dp = struct.unpack('>H', pt[4:6])[0], struct.unpack('>H', pt[6:8])[0] if typ == 1: # PDP seq = struct.unpack('>H', pt[8:10])[0] if len(pt) >= 10 else 0 pay = pt[12:ln] if 12 < ln <= len(pt) else pt[12:] s = 'PDP %04x->%04x seq %04x len %d' % (sp, dp, seq, ln) if len(pay) >= 4 and pay[0] == 1: # matching message op = pay[1] mln = struct.unpack('>H', pay[2:4])[0] s += ' | match op %d len %d %r' % (op, mln, bytes(pay[4:4 + min(mln, 16)])) return s if typ == 2: # PTP flags = pt[1] seq = struct.unpack('>I', pt[8:12])[0] if len(pt) >= 12 else 0 name = _PTP_FLAGS.get(flags, '?') s = 'PTP %04x->%04x %-7s seq %d len %d' % (sp, dp, name, seq, ln) if flags in (1, 2, 3, 5) and len(pt) >= 16: s += ' ack/wnd %d' % struct.unpack('>I', pt[12:16])[0] return s return 'type %02x len %d' % (typ, ln) # ---- pcap ---------------------------------------------------------------------------------- def read_frames(path): f = open(path, 'rb').read() magic = struct.unpack('= 4 and zlib.crc32(w[:-4]) & 0xFFFFFFFF != struct.unpack('> 2) & 3 != 2: continue hdr = 24 + (2 if (w[0] >> 4) & 8 else 0) body = w[hdr:] if body[:8] != bytes.fromhex('AAAA0300000088C8'): continue if t0 is None: t0 = t yield t - t0, w[10:16].hex(':'), w[4:10].hex(':'), body[8:] # ---- main ---------------------------------------------------------------------------------- def show_handshake(frames): pubs = {} for t, src, dst, b in frames: if b[:2] != b'\x00\x01': continue kind, mtype, ln = b[2], b[3], struct.unpack('>H', b[4:6])[0] print('%8.3f %s -> %s auth msg %02x/%02x len 0x%x' % (t, src, dst, kind, mtype, ln)) if mtype == 2: print(' (broadcast, plain) %s' % b[6:6 + ln].rstrip(b'\xff').hex()) continue pt = kirk7(KIRK_KEY_02, b[6:6 + ln]) for i in range(0, len(pt), 32): print(' %03x %s' % (i, pt[i:i + 32].hex())) pubs.setdefault(src, pt[0x10:0x38]) for src, pub in pubs.items(): print('ephemeral public key of %s (msg+0x10): %s on curve: %s' % (src, pub.hex(), on_curve(pub))) def main(): ap = argparse.ArgumentParser(description='Decrypt and decode PSP ad hoc traffic from a pcap.') ap.add_argument('pcap', nargs='?') ap.add_argument('--key', help='session key (hex): 16 bytes decrypts, 20 bytes also verifies tags') ap.add_argument('--known', action='append', default=[], metavar='HEX16@COUNTER', help='a known first-block plaintext (16 bytes) for the data frame at COUNTER; ' 'give two of opposite counter parity to recover the 16-byte key') ap.add_argument('--raw', action='store_true', help='show raw plaintext hex instead of decoded framing') ap.add_argument('--selftest', action='store_true') a = ap.parse_args() if a.selftest: k = bytes(range(20)) pt = b'\x01\x00\x00\x10\x77\x00\x77\x00ABCDSYN\x01' fr = data_encrypt(k, 0x12, pt) c, back, ok = data_decrypt(k, fr) assert back == pt and ok and c == 0x12 assert recover16([(c, fr[34:50], pt[:16]), (c + 1, data_encrypt(k, c + 1, pt)[34:50], pt[:16])]) == k[:16] print('selftest ok') return if not a.pcap: ap.error('a pcap is required (or --selftest)') frames = list(read_frames(a.pcap)) print('%d 0x88C8 frames' % len(frames)) show_handshake(frames) data = [f for f in frames if f[3][:2] == b'\x00\x02'] print('\n%d data frames' % len(data)) key = bytes.fromhex(a.key) if a.key else None if a.known: byc = {} for _, _, _, b in data: byc.setdefault(struct.unpack(' %s ctr %5d tag %s %s' % (t, src[-5:], dst[-5:], counter, tagstr, info)) if not key: # Without a key, show which counters both directions used (same keystream), as a hint for # where to look for known plaintext: XOR of two such ciphertexts is XOR of their plaintexts. same = [(c, v) for c, v in sorted(bycounter.items()) if len({s for s, _ in v}) > 1] for t, src, dst, b in data[:30]: print('%8.3f %s -> %s ctr %5d len %4d' % (t, src[-5:], dst[-5:], struct.unpack('