diff --git a/README.md b/README.md index 2b11e9e..7a15e96 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# NUSGet -A modern and supercharged NUS downloader built with Python and Qt6. Powered by libWiiPy and libTWLPy. +# NUSGet After Dark +A modern and supercharged NUS downloader built with Python and Qt6. Powered by libWiiPy and libTWLPy. Fork with features not acceptable for prod. [![Python application](https://github.com/NinjaCheetah/NUSGet/actions/workflows/python-build.yml/badge.svg)](https://github.com/NinjaCheetah/NUSGet/actions/workflows/python-build.yml) @@ -12,10 +12,11 @@ NUSGet also offers the ability to create vWii WADs that can be installed from wi The following features are available for all supported consoles: - Downloading encrypted contents (files like `00000000`, `00000001`, etc.) directly from the update servers for any title. -- Creating decrypted contents (*.app files) from the encrypted contents on the servers. Only supported for free titles. +- Creating decrypted contents (*.app files) from the encrypted contents on the servers. **For Wii and vWii titles only:** -- "Pack installable archive (WAD/TAD)": Pack the encrypted contents, TMD, and Ticket into a WAD file that can be installed on a Wii or in Dolphin Emulator. Only supported for free titles. +- "Pack installable archive (WAD/TAD)": Pack the encrypted contents, TMD, and Ticket into a WAD file that can be installed on a Wii or in Dolphin Emulator. +- Forging Tickets for titles without a common Ticket available on the NUS by using the Title Key algorithm to derive the key needed to decrypt the title. **For vWii titles only:** - "Re-encrypt title using the Wii Common Key": Re-encrypt the Title Key in a vWii title's Ticket before packing the WAD, so that the WAD can be installed via a normal WAD manager on the vWii, and can be extracted with legacy tools. **This also creates WADs that can be installed directly in Dolphin, allowing for running the vWii System Menu in Dolphin without a vWii NAND dump!** diff --git a/modules/download_wii.py b/modules/download_wii.py index 4302842..ede3358 100644 --- a/modules/download_wii.py +++ b/modules/download_wii.py @@ -4,8 +4,9 @@ import os import pathlib from typing import List, Tuple - +from .tkey import find_tkey import libWiiPy +from libWiiPy.title.ticket import _TitleLimit def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_wad_chkbox: bool, keep_enc_chkbox: bool, @@ -58,6 +59,7 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_ tmd_out.write(title.tmd.dump()) tmd_out.close() # Use a local ticket, if one exists and "use local files" is enabled. + forge_ticket = False if use_local_chkbox is True and os.path.exists(os.path.join(version_dir, "tik")): progress_callback.emit(" - Parsing local copy of Ticket...") local_ticket = open(os.path.join(version_dir, "tik"), "rb") @@ -70,11 +72,10 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_ ticket_out.write(title.ticket.dump()) ticket_out.close() except ValueError: - # If libWiiPy returns an error, then no ticket is available. Log this, and disable options requiring a - # ticket so that they aren't attempted later. - progress_callback.emit(" - No Ticket is available!") - pack_wad_enabled = False - decrypt_contents_enabled = False + # If libWiiPy returns an error, then no ticket is available. Try to forge a ticket after we download the + # content. + progress_callback.emit(" - No Ticket is available! Will try forging a Ticket.") + forge_ticket = True # Load the content records from the TMD, and begin iterating over the records. title.load_content_records() content_list = [] @@ -105,6 +106,39 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_ enc_content_out.write(content_list[content]) enc_content_out.close() title.content.content_list = content_list + # Try to forge a Ticket, if a common one wasn't available. + if forge_ticket is True: + progress_callback.emit(" - Attempting to forge Ticket...") + try: + title_key = find_tkey(tid, title.content.content_list[0], title.tmd.content_records[0]) + title_key_enc = libWiiPy.title.encrypt_title_key(title_key, 0, tid) + ticket = libWiiPy.title.Ticket() + ticket.common_key_index = 0 + ticket.console_id = 0 + ticket.content_access_permissions = b'\xff' * 64 + ticket.ecdh_data = b'\x00' * 60 + ticket.permit_mask = b'\x00' * 4 + ticket.permitted_titles = b'\x00' * 4 + ticket.signature = b'\x00' * 256 + ticket.signature_issuer = "Root-CA00000001-XS00000003" + ("\x00" * 38) + ticket.signature_type = b'\x00\x01' * 2 + ticket.ticket_id = b'\x00' * 8 + ticket.ticket_version = 0 + ticket.title_export_allowed = 0 + ticket.title_id = tid.encode() + ticket.title_key_enc = title_key_enc + ticket.title_limits_list = [_TitleLimit(0, 0) for _ in range(0, 8)] + ticket.title_version = 0 + ticket.unknown1 = b'\xff' * 2 + ticket.unknown2 = b'\x00' * 48 + ticket.fakesign() + title.ticket = ticket + open(os.path.join(version_dir, "tik"), "wb").write(title.ticket.dump()) + progress_callback.emit(" - Successfully forged Ticket!") + except Exception: + progress_callback.emit(" - Ticket could not be forged!") + pack_wad_enabled = False + decrypt_contents_enabled = False # If decrypt local contents is still true, decrypt each content and write out the decrypted file. if decrypt_contents_enabled is True: try: diff --git a/modules/tkey.py b/modules/tkey.py new file mode 100644 index 0000000..9978fdd --- /dev/null +++ b/modules/tkey.py @@ -0,0 +1,53 @@ +# "tkey-gen.py", licensed under the MIT license +# Copyright 2024 NinjaCheetah + +import binascii +import hashlib +import libWiiPy +from libWiiPy.types import _ContentRecord + + +def _secret(start, length): + ret = b'' + add = start + length + for _ in range(length): + unsigned_start = start & 0xFF # Compensates for how Python handles negative values vs PHP. + ret += bytes.fromhex(f"{unsigned_start:02x}"[-2:]) + nxt = start + add + add = start + start = nxt + return ret + + +def _mungetid(tid): + # Remove leading zeroes from the TID. + while tid.startswith("00"): + tid = tid[2:] + if tid == "": + tid = "00" + # In PHP, the last character just gets dropped if you make a hex string from an odd-length input, so this + # replicates that functionality. + if len(tid) % 2 != 0: + tid = tid[:-1] + return bytes.fromhex(tid) + + +def _derive_key(tid, passwd): + key_secret = _secret(-3, 10) + salt = hashlib.md5(key_secret + _mungetid(tid)).digest() + # Had to reduce the length here from 32 to 16 when converting to get the same length keys. + return hashlib.pbkdf2_hmac("sha1", passwd.encode(), salt, 20, 16).hex() + + +def find_tkey(tid: str, banner_enc: bytes, content_record: _ContentRecord) -> bytes: + # Find a working Title Key by generating a key with a password, then decrypting content 0 and comparing it to the + # expected hash. If the hash matches, then we generated the correct key. + passwds = ["nintendo", "mypass"] + for passwd in passwds: + key = binascii.unhexlify(_derive_key(tid, passwd).encode()) + banner_dec = libWiiPy.title.decrypt_content(banner_enc, key, content_record.index, content_record.content_size) + banner_dec_hash = hashlib.sha1(banner_dec).hexdigest() + content_record_hash = content_record.content_hash.decode() + if banner_dec_hash == content_record_hash: + return key + raise Exception("Valid Title Key could not be generated") diff --git a/packaging/icon.png b/packaging/icon.png index b6ebe42..cafa7b3 100644 Binary files a/packaging/icon.png and b/packaging/icon.png differ diff --git a/resources/icon.png b/resources/icon.png index b6ebe42..cafa7b3 100644 Binary files a/resources/icon.png and b/resources/icon.png differ