Files
ppsspp/Tools/adhoc_decrypt.py
T
Henrik RydgårdandClaude Opus 4.8 b7852eace2 Tools: adhoc_decrypt.py / adhoc_recover.c for debugging real-PSP ad hoc captures
A real PSP encrypts its ad hoc link, so a sniffed capture of two consoles is
ciphertext. These decrypt and decode it (PDP/PTP/matching framing), to compare
what a game does on hardware against what PPSSPP sends at the sceNetAdhoc HLE
level - a debugging aid for hardware-vs-emulator divergence. Not needed to run
multiplayer (PPSSPP works at HLE and never encrypts) and unrelated to cross-play
(the link is console-authenticated).

The session key is ephemeral and not in the capture, but the data cipher has a
known-plaintext weakness, so a capture with some guessable plaintext is enough
to recover it - no console memory needed, and not a break of the key exchange.
The two fixed KIRK keys used already ship in ext/libkirk. docs/adhoc-packet-
decryption.md covers capturing, decrypting and recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-30 10:24:15 +01:00

362 lines
15 KiB
Python
Executable File

#!/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('<Q', c) + (key16[0:8] if c & 1 == 0 else key16[8:16])
return kirk7(KIRK_KEY_44, blocks)
def data_tag(key20, hdr16, ct):
return hashlib.sha1(key20 + hdr16 + ct).digest()[:16]
def data_decrypt(key, body):
"""body = the 0x88C8 payload starting at 00 02. Returns (counter, plaintext, tag_ok).
A 16-byte key decrypts only (tag_ok=None); a 20-byte key also verifies the tag."""
hdr = body[2:18]
tag = body[18:34]
ct = body[34:]
ct = ct[:len(ct) - len(ct) % 16]
counter = struct.unpack('<Q', hdr[:8])[0]
ok = (data_tag(key, hdr, ct) == tag) if len(key) == 20 else None
pt = xor(ct, keystream(key[:16], counter, len(ct) // 16))
return counter, pt, ok
def data_encrypt(key20, counter, pt):
hdr = struct.pack('<Q', counter) + bytes(8)
ct = xor(pt, keystream(key20, counter, len(pt) // 16))
return b'\x00\x02' + hdr + data_tag(key20, hdr, ct) + ct
# ---- key recovery from known plaintext -----------------------------------------------------
#
# The first block's keystream = AES_dec(key44, C||half), so AES_enc(key44, ct[0:16] ^ pt[0:16])
# equals counter(LE64) || half. One AES_enc over a known first-block plaintext yields 8 key bytes,
# and the counter in bytes 0-7 confirming the guess is a 64-bit check. Two frames of opposite
# counter parity give the 16-byte key. The last 4 of the 20-byte key only feed the tag.
_RK44 = aes_expand(KIRK_KEY_44)
def half_from_known(counter, ct16, pt16):
blk = aes_enc(_RK44, xor(ct16, pt16))
return blk[8:16] if struct.unpack('<Q', blk[:8])[0] == counter else None
def recover16(knowns):
"""knowns: list of (counter, ct16, pt16). Returns the 16-byte key or None."""
halves = {}
for counter, ct16, pt16 in knowns:
h = half_from_known(counter, ct16, pt16)
if h:
halves[counter & 1] = h
return halves[0] + halves[1] if len(halves) == 2 else None
# ---- framing -------------------------------------------------------------------------------
#
# Decrypted payload is a PDP or PTP frame (same first 8 bytes: type, flags/00, length BE, src/dst
# port BE). PDP (type 01) then has a 16-bit sequence + 0000; a matching message rides in its
# payload as 01 opcode len16 data. PTP (type 02) has a flags byte and a 32-bit sequence, plus a
# 32-bit ack/window on control frames.
_PTP_FLAGS = {1: 'SYN', 2: 'SYN-ACK', 3: 'ACK', 4: 'DATA', 5: 'WND-ACK'}
def describe(pt):
if len(pt) < 8:
return 'short'
typ = pt[0]
ln = struct.unpack('>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('<I', f[:4])[0]
if magic not in (0xA1B2C3D4, 0xA1B23C4D):
sys.exit('not a little-endian pcap')
off = 24
t0 = None
while off + 16 <= len(f):
ts, tu, cl, ol = struct.unpack('<IIII', f[off:off + 16])
off += 16
p = f[off:off + cl]
off += cl
t = ts + (tu / 1e9 if magic == 0xA1B23C4D else tu / 1e6)
if len(p) < 8:
continue
rl = struct.unpack('<H', p[2:4])[0]
if len(p) < rl + 24:
continue
present = struct.unpack('<I', p[4:8])[0]
w = p[rl:]
# radiotap flags field: bit 0 TSFT (8 bytes, 8-aligned) precedes it
fcs = False
if present & 2:
o = 8
pp = present
while pp & 0x80000000: # extended bitmaps
pp = struct.unpack('<I', p[o:o + 4])[0]
o += 4
if present & 1:
o = (o + 7) & ~7
o += 8
fcs = bool(p[o] & 0x10)
if fcs:
if len(w) >= 4 and zlib.crc32(w[:-4]) & 0xFFFFFFFF != struct.unpack('<I', w[-4:])[0]:
continue # bad FCS
w = w[:-4]
if len(w) < 24 or (w[0] >> 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('<Q', b[2:10])[0], b[34:50])
knowns = []
for item in a.known:
pthex, _, ctr = item.partition('@')
c = int(ctr)
if c not in byc:
sys.exit('no data frame at counter %d' % c)
knowns.append((c, byc[c], bytes.fromhex(pthex)))
key = recover16(knowns)
if not key:
sys.exit('could not recover the key: need one even and one odd counter, '
'and the known plaintext must be correct')
print('recovered 16-byte key: %s' % key.hex())
print('(decrypts; for tag verification recover the full 20-byte key with adhoc_recover.c)')
bycounter = {}
for t, src, dst, b in data:
counter = struct.unpack('<Q', b[2:10])[0]
bycounter.setdefault(counter, []).append((src, b[34:]))
if not key:
continue
counter, pt, ok = data_decrypt(key, b)
tagstr = '- ' if ok is None else ('ok ' if ok else 'BAD')
info = pt[:48].hex() if a.raw else describe(pt)
print('%8.3f %s -> %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('<Q', b[2:10])[0], len(b) - 34))
print('\n%d counters used by both directions (keystream reuse); XOR of the two is the plaintext XOR' % len(same))
if __name__ == '__main__':
main()