Skip to content

Instantly share code, notes, and snippets.

@WitherOrNot
Last active May 14, 2023 20:11
Show Gist options
  • Select an option

  • Save WitherOrNot/bc7259ccc5496a61280e32093bcbadbb to your computer and use it in GitHub Desktop.

Select an option

Save WitherOrNot/bc7259ccc5496a61280e32093bcbadbb to your computer and use it in GitHub Desktop.
Decrypt Wii U NUS downloads for Loadiine/emulator
#!/usr/bin/env python3
from struct import pack, unpack, calcsize
from Crypto.Cipher import AES
from Crypto.Hash import SHA1
from binascii import hexlify, unhexlify
from io import BytesIO
from os.path import join
from os import makedirs
import sys
COMMON_DEV_KEY = b"/\\\x1b)D\xe7\xfdo\xc3\x97\x96K\x05v\x91\xfa"
COMMON_RETAIL_KEY = b"\xd7\xb0\x04\x02e\x9b\xa2\xab\xd2\xcb\r\xb2\x7f\xa2\xb6V"
COMMON_KEY = b""
EXTRACT_ROOT = ""
CONTENT_ROOT = ""
def hexify(s):
return hexlify(s).decode("utf-8")
def ihexify(n, b):
return hex(n)[2:].zfill(b * 2)
def aes_decrypt(key, iv, ctext):
iv += b"\x00" * (16 - len(iv))
aes = AES.new(key, AES.MODE_CBC, iv)
return aes.decrypt(ctext)
def sha1(s):
sha = SHA1.new()
sha.update(s)
return sha.digest()
def readint(f):
return unpack(">I", f.read(4))[0]
def readstc(f, s):
s = ">" + s
sz = calcsize(s)
return unpack(s, f.read(sz))
def readstr(f):
s = ""
c = f.read(1)
while c[0] != 0:
s += chr(c[0])
c = f.read(1)
return s
def parse_ticket(ticket_path):
with open(ticket_path, "rb") as ticket_file:
ticket_file.seek(0x1BF)
titlekey = ticket_file.read(16)
ticket_file.seek(0x1DC)
titleid = ticket_file.read(8)
return titleid, titlekey
def parse_tmd(tmd_path):
global COMMON_KEY
with open(tmd_path, "rb") as tmd_file:
tmd_file.seek(0x140)
issuer = tmd_file.read(0x40)
if b"Root-CA00000003-CP0000000b" in issuer:
COMMON_KEY = COMMON_RETAIL_KEY
elif b"Root-CA00000004-CP00000010" in issuer:
COMMON_KEY = COMMON_DEV_KEY
tmd_file.seek(0x1DE)
nbr_conts = unpack(">H", tmd_file.read(2))[0]
tmd_file.seek(0xB04)
contents = {}
for i in range(nbr_conts):
c = tmd_file.read(48)
cid = unpack(">H", c[4:6])[0]
contents[cid] = (hexify(c[:4]), c[7] & 2 == 2)
return contents
def extract_file(path, name, foff, flen, cid):
BLOCK_SIZE = 0x8000
roffset = (foff // BLOCK_SIZE) * BLOCK_SIZE
soffset = foff % BLOCK_SIZE
iv = pack(">H", cid)
size = flen
wsize = BLOCK_SIZE
aes = AES.new(titlekey, AES.MODE_CBC, iv + b"\x00" * 14)
if soffset + size > wsize:
wsize -= soffset
makedirs(join(*path), exist_ok=True)
fpath = join(*path, name)
cpath = join(CONTENT_ROOT, contents[cid][0] + ".app")
with open(cpath, "rb") as ef, open(fpath, "wb") as df:
ef.seek(roffset)
while size > 0:
wsize = min(wsize, size)
enc = ef.read(BLOCK_SIZE)
dec = aes.decrypt(enc)
size -= df.write(dec[soffset:soffset + wsize])
if soffset > 0:
wsize = BLOCK_SIZE
soffset = 0
def extract_hashed_file(path, name, foff, flen, cid):
BLOCK_SIZE = 0x10000
HASH_BLOCK_SIZE = 0xFC00
HASHES_SIZE = 0x400
roffset = (foff // HASH_BLOCK_SIZE) * BLOCK_SIZE
soffset = foff % HASH_BLOCK_SIZE
size = flen
wsize = HASH_BLOCK_SIZE
block_num = (foff // HASH_BLOCK_SIZE) & 0x0F
if soffset + size > wsize:
wsize -= soffset
makedirs(join(*path), exist_ok=True)
fpath = join(*path, name)
cpath = join(CONTENT_ROOT, contents[cid][0] + ".app")
with open(cpath, "rb") as ef, open(fpath, "wb") as df:
ef.seek(roffset)
while size > 0:
wsize = min(wsize, size)
enc = ef.read(BLOCK_SIZE)
iv = pack(">H", cid)
hashes = aes_decrypt(titlekey, iv, enc)[:HASHES_SIZE]
h0 = hashes[0x14 * block_num:0x14 * (block_num + 1)]
iv = h0[:0x10]
if block_num == 0:
iv = bytearray(iv)
iv[1] ^= cid
iv = bytes(iv)
dec = aes_decrypt(titlekey, iv, enc[HASHES_SIZE:])
chash = sha1(dec)
if block_num == 0:
chash = bytearray(chash)
chash[1] ^= cid
chash = bytes(chash)
if chash != h0:
print(f"WARN: Hash Mismatch, {hexify(chash)} != {hexify(h0)}. Continuing.")
size -= df.write(dec[soffset:soffset + wsize])
block_num = (block_num + 1) % 16
if soffset > 0:
wsize = HASH_BLOCK_SIZE
soffset = 0
def extract_files(fst_data):
f = BytesIO(fst_data)
f.read(8)
entry_count = readint(f)
f.seek(0x20 + entry_count * 0x20 + 0x8)
entries = readint(f)
name_offset = 0x20 + entry_count * 0x20 + entries * 0x10
nxt = []
path = [EXTRACT_ROOT]
for i in range(1, entries):
eoff = 0x20 + entry_count * 0x20 + i * 0x10
f.seek(eoff)
ftype, noff1, noff2 = readstc(f, "BBH")
noff = (noff1 << 16) | noff2
f.seek(name_offset + noff)
name = readstr(f)
f.seek(eoff + 4)
if len(nxt) > 0:
while len(nxt) >= 1 and nxt[-1] == i:
nxt.pop()
path.pop()
if ftype & 1:
poff, nxoff = readstc(f, "II")
print(f"{name} - Index: {ihexify(i, 4)} FST: {ihexify(eoff, 4)} Parent: {ihexify(poff, 4)} Next: {ihexify(nxoff, 4)}")
path.append(name)
nxt.append(nxoff)
elif not ftype & 0x80:
foff, flen, flags, cid = readstc(f, "IIHH")
if not flags & 4:
foff <<= 5
fpath = join(*path[1:], name)
print(f"{fpath} - Index: {ihexify(i, 4)} FST: {ihexify(eoff, 4)} Offset: {ihexify(foff, 4)} Size: {ihexify(flen, 4)} Flags: {ihexify(flags, 2)} Content: {ihexify(cid, 2)}")
if flags & 0x400 or flags & 0x40:
extract_hashed_file(path, name, foff, flen, cid)
else:
extract_file(path, name, foff, flen, cid)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Decrypts NUS files to Loadiine/emulator compatible files.")
print(f"Usage: {sys.argv[0]} in_dir out_dir")
exit(1)
CONTENT_ROOT = sys.argv[1]
EXTRACT_ROOT = sys.argv[2]
contents = parse_tmd(join(CONTENT_ROOT, "title.tmd"))
titleid, titlekey = parse_ticket(join(CONTENT_ROOT, "title.tik"))
titlekey = aes_decrypt(COMMON_KEY, titleid, titlekey)
with open(join(CONTENT_ROOT, contents[0][0] + ".app"), "rb") as f:
fst_data = aes_decrypt(titlekey, b"\x00", f.read())
extract_files(fst_data)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment