From a2c4c850a84fb62f553a92c8d3d7b6bc63eb6c5e Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Sat, 2 Mar 2024 23:59:02 -0500 Subject: [PATCH] Partially working code for decrypting content --- src/libWiiPy/content.py | 88 +++++++++++++++++++++++++++++++++++++ src/libWiiPy/crypto.py | 41 +++++++++++++++++ src/libWiiPy/ticket.py | 80 +++++++++++++++++---------------- src/libWiiPy/tmd.py | 97 +++++++++++++++-------------------------- src/libWiiPy/types.py | 29 ++++++++++++ src/libWiiPy/wad.py | 34 +++++++-------- 6 files changed, 254 insertions(+), 115 deletions(-) create mode 100644 src/libWiiPy/content.py create mode 100644 src/libWiiPy/types.py diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py new file mode 100644 index 0000000..e009060 --- /dev/null +++ b/src/libWiiPy/content.py @@ -0,0 +1,88 @@ +# "content.py" from libWiiPy by NinjaCheetah & Contributors +# https://github.com/NinjaCheetah/libWiiPy +# +# See https://wiibrew.org/wiki/Title for details about how titles are formatted + +import io +import sys +import hashlib +from typing import List +from .types import ContentRecord +from .crypto import decrypt_content + + +class ContentRegion: + """Creates a ContentRegion object to parse the continuous content region of a WAD. + + Attributes: + ---------- + content_region : bytes + A bytes object containing the content region of a WAD file. + content_records : list[ContentRecord] + A list of ContentRecord objects detailing all contents contained in the region. + """ + + def __init__(self, content_region, content_records: List[ContentRecord]): + self.content_region = content_region + self.content_records = content_records + self.content_region_size: int # Size of the content region. + self.num_contents: int # Number of contents in the content region. + self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region. + + with io.BytesIO(content_region) as content_region_data: + # Get the total size of the content region. + self.content_region_size = sys.getsizeof(content_region_data) + self.num_contents = len(self.content_records) + # Calculate the offsets of each content in the content region. + for content in self.content_records[:-1]: + start_offset = content.content_size + self.content_start_offsets[-1] + self.content_start_offsets.append(start_offset) + + def get_enc_content(self, index: int) -> bytes: + """Gets an individual content from the content region based on the provided content record, in encrypted form. + + Parameters + ---------- + index : int + The index of the content you want to get. + + Returns + ------- + bytes + The encrypted content listed in the content record. + """ + with io.BytesIO(self.content_region) as content_region_data: + # Seek to the start of the requested content based on the list of offsets. + content_region_data.seek(self.content_start_offsets[index]) + # Read the file based on the size of the content in the associated record. + content_enc = content_region_data.read(self.content_records[index].content_size) + return content_enc + + def get_content(self, index: int, title_key: bytes) -> bytes: + """Gets an individual content from the content region based on the provided content record, in decrypted form. + + Parameters + ---------- + index : int + The index of the content you want to get. + title_key : bytes + The Title Key for the title the content is from. + + Returns + ------- + bytes + The decrypted content listed in the content record. + """ + # Load the encrypted content at the specified index and then decrypt it with the Title Key. + content_enc = self.get_enc_content(index) + content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index) + # Hash the decrypted content and ensure that the hash matches the one in its Content Record. + # If it does not, then something has gone wrong in the decryption, and an error will be thrown. + content_dec_hash = hashlib.sha1(content_dec) + content_record_hash = str(self.content_records[index].content_hash.decode()) + if content_dec_hash.hexdigest() != content_record_hash: + raise ValueError("Content hash did not match the expected hash in its record! This most likely means that " + "the incorrect Title Key was used for this content.\n" + "Expected hash is: {}\n".format(content_record_hash) + + "Actual hash is: {}".format(content_dec_hash.hexdigest())) + return content_dec diff --git a/src/libWiiPy/crypto.py b/src/libWiiPy/crypto.py index 44a4a8f..ea41008 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -3,8 +3,10 @@ # # See https://wiibrew.org/wiki/Ticket for details about the TMD format +import struct from .commonkeys import get_common_key from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad def decrypt_title_key(title_key_enc, common_key_index, title_id): @@ -35,3 +37,42 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id): # Decrypt the Title Key using the AES object. title_key = aes.decrypt(title_key_enc) return title_key + + +def decrypt_content(content_enc, title_key, content_index): + """Gets the decrypted version of the encrypted content. + + Requires the index of the common key to use, and the Title ID of the title that the Title Key is for. + + Parameters + ---------- + content_enc : bytes + The encrypted content. + title_key : bytes + The Title Key for the title the content is from. + + Returns + ------- + bytes + The decrypted content. + """ + # Generate the IV from the Content Index of the content to be decrypted. + content_index_bin = struct.pack('>H', content_index) + while len(content_index_bin) < 16: + content_index_bin += b'\x00' + # In CBC mode, content must be padded out to a 16-byte boundary, so do that here, and then remove bytes added after. + padding_count = 0 + content_enc = pad(content_enc, 16, "pkcs7") + #while (len(content_enc) % 16) != 0: + #content_enc += b'\x00' + #padding_count += 1 + # Create a new AES object with the values provided, with the content's unique ID as the IV. + aes = AES.new(title_key, AES.MODE_CBC, content_index_bin) + # Decrypt the content using the AES object. + content_dec = aes.decrypt(content_enc) + # Remove padding bytes, if any were added. + #content_dec = unpad(content_dec, 128) + file = open("out", "wb") + file.write(content_dec) + file.close() + return content_dec diff --git a/src/libWiiPy/ticket.py b/src/libWiiPy/ticket.py index 637352f..85a99de 100644 --- a/src/libWiiPy/ticket.py +++ b/src/libWiiPy/ticket.py @@ -29,7 +29,13 @@ class TitleLimit: class Ticket: - """Creates a Ticket object to parse a Ticket file to retrieve the Title Key needed to decrypt it.""" + """Creates a Ticket object to parse a Ticket file to retrieve the Title Key needed to decrypt it. + + Attributes: + ---------- + ticket : bytes + A bytes object containing the contents of a ticket file. + """ def __init__(self, ticket): self.ticket = ticket @@ -53,63 +59,63 @@ class Ticket: self.title_limits_list: List[TitleLimit] = [] # List of play limits applied to the title. # v1 ticket data # TODO: Figure out v1 ticket stuff - with io.BytesIO(self.ticket) as ticketdata: + with io.BytesIO(self.ticket) as ticket_data: # ==================================================================================== # Parses each of the keys contained in the Ticket. # ==================================================================================== # Signature type - ticketdata.seek(0x0) - self.signature_type = ticketdata.read(4) + ticket_data.seek(0x0) + self.signature_type = ticket_data.read(4) # Signature data - ticketdata.seek(0x04) - self.signature = ticketdata.read(256) + ticket_data.seek(0x04) + self.signature = ticket_data.read(256) # Signature issuer - ticketdata.seek(0x140) - self.signature_issuer = str(ticketdata.read(64).decode()) + ticket_data.seek(0x140) + self.signature_issuer = str(ticket_data.read(64).decode()) # ECDH data - ticketdata.seek(0x180) - self.ecdh_data = ticketdata.read(60) + ticket_data.seek(0x180) + self.ecdh_data = ticket_data.read(60) # Ticket version - ticketdata.seek(0x1BC) - self.ticket_version = int.from_bytes(ticketdata.read(1)) + ticket_data.seek(0x1BC) + self.ticket_version = int.from_bytes(ticket_data.read(1)) # Title Key (Encrypted by a common key) - ticketdata.seek(0x1BF) - self.title_key_enc = ticketdata.read(16) + ticket_data.seek(0x1BF) + self.title_key_enc = ticket_data.read(16) # Ticket ID - ticketdata.seek(0x1D0) - self.ticket_id = ticketdata.read(8) + ticket_data.seek(0x1D0) + self.ticket_id = ticket_data.read(8) # Console ID - ticketdata.seek(0x1D8) - self.console_id = int.from_bytes(ticketdata.read(4)) + ticket_data.seek(0x1D8) + self.console_id = int.from_bytes(ticket_data.read(4)) # Title ID - ticketdata.seek(0x1DC) - self.title_id = ticketdata.read(8) + ticket_data.seek(0x1DC) + self.title_id = ticket_data.read(8) # Title version - ticketdata.seek(0x1E6) - title_version_high = int.from_bytes(ticketdata.read(1)) * 256 - ticketdata.seek(0x1E7) - title_version_low = int.from_bytes(ticketdata.read(1)) + ticket_data.seek(0x1E6) + title_version_high = int.from_bytes(ticket_data.read(1)) * 256 + ticket_data.seek(0x1E7) + title_version_low = int.from_bytes(ticket_data.read(1)) self.title_version = title_version_high + title_version_low # Permitted titles mask - ticketdata.seek(0x1E8) - self.permitted_titles = ticketdata.read(4) + ticket_data.seek(0x1E8) + self.permitted_titles = ticket_data.read(4) # Permit mask - ticketdata.seek(0x1EC) - self.permit_mask = ticketdata.read(4) + ticket_data.seek(0x1EC) + self.permit_mask = ticket_data.read(4) # Whether title export with a PRNG key is allowed - ticketdata.seek(0x1F0) - self.title_export_allowed = int.from_bytes(ticketdata.read(1)) + ticket_data.seek(0x1F0) + self.title_export_allowed = int.from_bytes(ticket_data.read(1)) # Common key index - ticketdata.seek(0x1F1) - self.common_key_index = int.from_bytes(ticketdata.read(1)) + ticket_data.seek(0x1F1) + self.common_key_index = int.from_bytes(ticket_data.read(1)) # Content access permissions - ticketdata.seek(0x222) - self.content_access_permissions = ticketdata.read(64) + ticket_data.seek(0x222) + self.content_access_permissions = ticket_data.read(64) # Content limits - ticketdata.seek(0x264) + ticket_data.seek(0x264) for limit in range(0, 8): - limit_type = int.from_bytes(ticketdata.read(4)) - limit_value = int.from_bytes(ticketdata.read(4)) + limit_type = int.from_bytes(ticket_data.read(4)) + limit_value = int.from_bytes(ticket_data.read(4)) self.title_limits_list.append(TitleLimit(limit_type, limit_value)) def get_signature(self): diff --git a/src/libWiiPy/tmd.py b/src/libWiiPy/tmd.py index d9e62df..f0ed6e3 100644 --- a/src/libWiiPy/tmd.py +++ b/src/libWiiPy/tmd.py @@ -6,33 +6,8 @@ import io import binascii import struct -from dataclasses import dataclass from typing import List - - -@dataclass -class ContentRecord: - """ - Creates a content record object that contains the details of a content contained in a title. - - Attributes: - ---------- - cid : int - ID of the content. - index : int - Index of the content in the list of contents. - content_type : int - The type of the content. - content_size : int - The size of the content. - content_hash - The SHA-1 hash of the decrypted content. - """ - cid: int # Content ID - index: int # Index in the list of contents - content_type: int # Normal: 0x0001, DLC: 0x4001, Shared: 0x8001 - content_size: int - content_hash: bytes # SHA1 hash content +from .types import ContentRecord class TMD: @@ -66,71 +41,71 @@ class TMD: self.boot_index: int self.content_records: List[ContentRecord] = [] # Load data from TMD file - with io.BytesIO(self.tmd) as tmddata: + with io.BytesIO(self.tmd) as tmd_data: # ==================================================================================== # Parses each of the keys contained in the TMD. # ==================================================================================== # Signing certificate issuer - tmddata.seek(0x140) - self.issuer = tmddata.read(64) + tmd_data.seek(0x140) + self.issuer = tmd_data.read(64) # TMD version, seems to usually be 0, but I've seen references to other numbers - tmddata.seek(0x180) - self.version = int.from_bytes(tmddata.read(1)) + tmd_data.seek(0x180) + self.version = int.from_bytes(tmd_data.read(1)) # TODO: label - tmddata.seek(0x181) - self.ca_crl_version = tmddata.read(1) + tmd_data.seek(0x181) + self.ca_crl_version = tmd_data.read(1) # TODO: label - tmddata.seek(0x182) - self.signer_crl_version = tmddata.read(1) + tmd_data.seek(0x182) + self.signer_crl_version = tmd_data.read(1) # If this is a vWii title or not - tmddata.seek(0x183) - self.vwii = int.from_bytes(tmddata.read(1)) + tmd_data.seek(0x183) + self.vwii = int.from_bytes(tmd_data.read(1)) # TID of the IOS to use for the title, set to 0 if this title is the IOS, set to boot2 version if boot2 - tmddata.seek(0x184) - ios_version_bin = tmddata.read(8) + tmd_data.seek(0x184) + ios_version_bin = tmd_data.read(8) ios_version_hex = binascii.hexlify(ios_version_bin) self.ios_tid = str(ios_version_hex.decode()) # Get IOS version based on TID self.ios_version = int(self.ios_tid[-2:], 16) # Title ID of the title - tmddata.seek(0x18C) - title_id_bin = tmddata.read(8) + tmd_data.seek(0x18C) + title_id_bin = tmd_data.read(8) title_id_hex = binascii.hexlify(title_id_bin) self.title_id = str(title_id_hex.decode()) # Type of content - tmddata.seek(0x194) - content_type_bin = tmddata.read(4) + tmd_data.seek(0x194) + content_type_bin = tmd_data.read(4) content_type_hex = binascii.hexlify(content_type_bin) self.content_type = str(content_type_hex.decode()) # Publisher of the title - tmddata.seek(0x198) - self.group_id = tmddata.read(2) + tmd_data.seek(0x198) + self.group_id = tmd_data.read(2) # Region of the title, 0 = JAP, 1 = USA, 2 = EUR, 3 = NONE, 4 = KOR - tmddata.seek(0x19C) - region_hex = tmddata.read(2) + tmd_data.seek(0x19C) + region_hex = tmd_data.read(2) self.region = int.from_bytes(region_hex) # TODO: figure this one out - tmddata.seek(0x19E) - self.ratings = tmddata.read(16) + tmd_data.seek(0x19E) + self.ratings = tmd_data.read(16) # Access rights of the title; DVD-video access and AHBPROT - tmddata.seek(0x1D8) - self.access_rights = tmddata.read(4) + tmd_data.seek(0x1D8) + self.access_rights = tmd_data.read(4) # Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD - tmddata.seek(0x1DC) - title_version_high = int.from_bytes(tmddata.read(1)) * 256 - tmddata.seek(0x1DD) - title_version_low = int.from_bytes(tmddata.read(1)) + tmd_data.seek(0x1DC) + title_version_high = int.from_bytes(tmd_data.read(1)) * 256 + tmd_data.seek(0x1DD) + title_version_low = int.from_bytes(tmd_data.read(1)) self.title_version = title_version_high + title_version_low # The number of contents listed in the TMD - tmddata.seek(0x1DE) - self.num_contents = int.from_bytes(tmddata.read(2)) + tmd_data.seek(0x1DE) + self.num_contents = int.from_bytes(tmd_data.read(2)) # Content index in content list that contains the boot file - tmddata.seek(0x1E0) - self.boot_index = tmddata.read(2) + tmd_data.seek(0x1E0) + self.boot_index = tmd_data.read(2) # Get content records for the number of contents in num_contents. for content in range(0, self.num_contents): - tmddata.seek(0x1E4 + (36 * content)) - content_record_hdr = struct.unpack(">LHH4x4s20s", tmddata.read(36)) + tmd_data.seek(0x1E4 + (36 * content)) + content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36)) self.content_records.append( ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]), int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]), diff --git a/src/libWiiPy/types.py b/src/libWiiPy/types.py new file mode 100644 index 0000000..85dd87b --- /dev/null +++ b/src/libWiiPy/types.py @@ -0,0 +1,29 @@ +# "types.py" from libWiiPy by NinjaCheetah & Contributors +# https://github.com/NinjaCheetah/libWiiPy + +from dataclasses import dataclass + + +@dataclass +class ContentRecord: + """ + Creates a content record object that contains the details of a content contained in a title. + + Attributes: + ---------- + content_id : int + ID of the content. + index : int + Index of the content in the list of contents. + content_type : int + The type of the content. + content_size : int + The size of the content. + content_hash + The SHA-1 hash of the decrypted content. + """ + content_id: int # Unique ID for the current content + index: int # Index in the list of contents + content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared + content_size: int # Size of the current content + content_hash: bytes # SHA1 hash of the current content diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index 6515b45..37516c7 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -37,36 +37,36 @@ class WAD: self.wad_content_offset: int self.wad_meta_offset: int # Load header data from WAD stream - with io.BytesIO(self.wad) as waddata: + with io.BytesIO(self.wad) as wad_data: # ==================================================================================== # Get the sizes of each data region contained within the WAD. # ==================================================================================== # Header length, which will always be 64 bytes, as it is padded out if it is shorter. self.wad_hdr_size = 64 # WAD type, denoting whether this WAD contains boot2 ("ib"), or anything else ("Is"). - waddata.seek(0x04) - self.wad_type = str(waddata.read(2).decode()) + wad_data.seek(0x04) + self.wad_type = str(wad_data.read(2).decode()) # WAD version, this is always 0. - waddata.seek(0x06) - self.wad_version = waddata.read(2) + wad_data.seek(0x06) + self.wad_version = wad_data.read(2) # WAD cert size. - waddata.seek(0x08) - self.wad_cert_size = int(binascii.hexlify(waddata.read(4)), 16) + wad_data.seek(0x08) + self.wad_cert_size = int(binascii.hexlify(wad_data.read(4)), 16) # WAD crl size. - waddata.seek(0x0c) - self.wad_crl_size = int(binascii.hexlify(waddata.read(4)), 16) + wad_data.seek(0x0c) + self.wad_crl_size = int(binascii.hexlify(wad_data.read(4)), 16) # WAD ticket size. - waddata.seek(0x10) - self.wad_tik_size = int(binascii.hexlify(waddata.read(4)), 16) + wad_data.seek(0x10) + self.wad_tik_size = int(binascii.hexlify(wad_data.read(4)), 16) # WAD TMD size. - waddata.seek(0x14) - self.wad_tmd_size = int(binascii.hexlify(waddata.read(4)), 16) + wad_data.seek(0x14) + self.wad_tmd_size = int(binascii.hexlify(wad_data.read(4)), 16) # WAD content size. - waddata.seek(0x18) - self.wad_content_size = int(binascii.hexlify(waddata.read(4)), 16) + wad_data.seek(0x18) + self.wad_content_size = int(binascii.hexlify(wad_data.read(4)), 16) # Publisher of the title contained in the WAD. - waddata.seek(0x1c) - self.wad_meta_size = int(binascii.hexlify(waddata.read(4)), 16) + wad_data.seek(0x1c) + self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16) # ==================================================================================== # Calculate file offsets from sizes. Every section of the WAD is padded out to a multiple of 0x40. # ====================================================================================