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 1/4] 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. # ==================================================================================== From 413b7a371f3df98ae46b89d929f72eaea6275608 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Sun, 3 Mar 2024 15:18:58 -0500 Subject: [PATCH 2/4] More adjustments to the content extraction code --- src/libWiiPy/content.py | 12 +++++++++--- src/libWiiPy/crypto.py | 16 +++++++--------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index e009060..2d47b0c 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -35,7 +35,7 @@ class ContentRegion: 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] + start_offset = int(16 * round(content.content_size / 16)) + self.content_start_offsets[-1] self.content_start_offsets.append(start_offset) def get_enc_content(self, index: int) -> bytes: @@ -54,8 +54,10 @@ class ContentRegion: 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]) + # Calculate the number of bytes we need to read by rounding the size to the nearest 16 bytes. + bytes_to_read = int(16 * round(self.content_records[index].content_size / 16)) # 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) + content_enc = content_region_data.read(bytes_to_read) return content_enc def get_content(self, index: int, title_key: bytes) -> bytes: @@ -81,7 +83,11 @@ class ContentRegion: 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 " + #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())) + print("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())) diff --git a/src/libWiiPy/crypto.py b/src/libWiiPy/crypto.py index ea41008..7c4c0ee 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -61,18 +61,16 @@ def decrypt_content(content_enc, title_key, 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 + padded = False + if (len(content_enc) % 64) != 0: + print("needs padding to 16 bytes") + content_enc = pad(content_enc, 64, "pkcs7") + padded = True # 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() + #if padded: + #content_dec = unpad(content_dec, AES.block_size) return content_dec From b3923cfe405d78b0262f537525de8ae011eaaf02 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Sun, 3 Mar 2024 22:05:30 -0500 Subject: [PATCH 3/4] This makes 00000001.app work --- src/libWiiPy/content.py | 6 +++--- src/libWiiPy/crypto.py | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index 2d47b0c..5eebe2b 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -35,7 +35,7 @@ class ContentRegion: 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 = int(16 * round(content.content_size / 16)) + self.content_start_offsets[-1] + start_offset = int(64 * round(content.content_size / 64)) + self.content_start_offsets[-1] self.content_start_offsets.append(start_offset) def get_enc_content(self, index: int) -> bytes: @@ -55,7 +55,7 @@ class ContentRegion: # Seek to the start of the requested content based on the list of offsets. content_region_data.seek(self.content_start_offsets[index]) # Calculate the number of bytes we need to read by rounding the size to the nearest 16 bytes. - bytes_to_read = int(16 * round(self.content_records[index].content_size / 16)) + bytes_to_read = int(64 * round(self.content_records[index].content_size / 64)) # Read the file based on the size of the content in the associated record. content_enc = content_region_data.read(bytes_to_read) return content_enc @@ -77,7 +77,7 @@ class ContentRegion: """ # 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) + content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index, self.content_records[index].content_size) # 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) diff --git a/src/libWiiPy/crypto.py b/src/libWiiPy/crypto.py index 7c4c0ee..4e8abef 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -39,7 +39,7 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id): return title_key -def decrypt_content(content_enc, title_key, content_index): +def decrypt_content(content_enc, title_key, content_index, content_length): """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. @@ -62,15 +62,16 @@ def decrypt_content(content_enc, title_key, content_index): 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. padded = False - if (len(content_enc) % 64) != 0: + if (len(content_enc) % 128) != 0: print("needs padding to 16 bytes") - content_enc = pad(content_enc, 64, "pkcs7") + content_enc = pad(content_enc, 128, "pkcs7") padded = True # 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. - #if padded: - #content_dec = unpad(content_dec, AES.block_size) + if padded: + while len(content_dec) > content_length: + content_dec = content_dec[:-1] return content_dec From 7ca46372b0c1c7caccfe1273b59f3ab9c87608f1 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:48:38 -0500 Subject: [PATCH 4/4] Full content extraction is working! --- src/libWiiPy/content.py | 24 ++++++++++++++---------- src/libWiiPy/crypto.py | 29 ++++++++++++++--------------- src/libWiiPy/ticket.py | 2 +- src/libWiiPy/types.py | 10 +++++----- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index 5eebe2b..96dbce2 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -34,8 +34,12 @@ class ContentRegion: 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. + # Content is aligned to 16 bytes, however a new content won't start until the next multiple of 64 bytes. + # Because of this, we need to add bytes to the next 64 byte offset if the previous content wasn't that long. for content in self.content_records[:-1]: - start_offset = int(64 * round(content.content_size / 64)) + self.content_start_offsets[-1] + start_offset = content.content_size + self.content_start_offsets[-1] + if (content.content_size % 64) != 0: + start_offset += 64 - (content.content_size % 64) self.content_start_offsets.append(start_offset) def get_enc_content(self, index: int) -> bytes: @@ -54,8 +58,10 @@ class ContentRegion: 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]) - # Calculate the number of bytes we need to read by rounding the size to the nearest 16 bytes. - bytes_to_read = int(64 * round(self.content_records[index].content_size / 64)) + # Calculate the number of bytes we need to read by adding bytes up the nearest multiple of 16 if needed. + bytes_to_read = self.content_records[index].content_size + if (bytes_to_read % 16) != 0: + bytes_to_read += 16 - (bytes_to_read % 16) # Read the file based on the size of the content in the associated record. content_enc = content_region_data.read(bytes_to_read) return content_enc @@ -77,18 +83,16 @@ class ContentRegion: """ # 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, self.content_records[index].content_size) + content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index, + self.content_records[index].content_size) # 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()) + # Compare the hash and throw a ValueError if the hash doesn't match. 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())) - print("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" + raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may" + "have been used!.\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 4e8abef..2a6d538 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -1,7 +1,5 @@ # "crypto.py" from libWiiPy by NinjaCheetah & Contributors # https://github.com/NinjaCheetah/libWiiPy -# -# See https://wiibrew.org/wiki/Ticket for details about the TMD format import struct from .commonkeys import get_common_key @@ -9,7 +7,7 @@ from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad -def decrypt_title_key(title_key_enc, common_key_index, title_id): +def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes: """Gets the decrypted version of the encrypted Title Key provided. Requires the index of the common key to use, and the Title ID of the title that the Title Key is for. @@ -21,7 +19,7 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id): common_key_index : int The index of the common key to be returned. title_id : bytes - The title ID of the tite that the key is for. + The title ID of the title that the key is for. Returns ------- @@ -39,7 +37,7 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id): return title_key -def decrypt_content(content_enc, title_key, content_index, content_length): +def decrypt_content(content_enc, title_key, content_index, content_length) -> bytes: """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. @@ -50,6 +48,10 @@ def decrypt_content(content_enc, title_key, content_index, content_length): The encrypted content. title_key : bytes The Title Key for the title the content is from. + content_index : int + The index in the TMD's content record of the content being decrypted. + content_length : int + The length in the TMD's content record of the content being decrypted. Returns ------- @@ -60,18 +62,15 @@ def decrypt_content(content_enc, title_key, content_index, content_length): 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. - padded = False - if (len(content_enc) % 128) != 0: - print("needs padding to 16 bytes") - content_enc = pad(content_enc, 128, "pkcs7") - padded = True + # Align content to 64 bytes to ensure that all the data is being decrypted, and so it works with AES encryption. + if (len(content_enc) % 64) != 0: + print("needs padding to 64 bytes") + content_enc = content_enc + (b'\x00' * (64 - (len(content_enc) % 64))) # 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. - if padded: - while len(content_dec) > content_length: - content_dec = content_dec[:-1] + # Trim additional bytes that may have been added so the content is the correct size. + while len(content_dec) > content_length: + content_dec = content_dec[:-1] return content_dec diff --git a/src/libWiiPy/ticket.py b/src/libWiiPy/ticket.py index 85a99de..ee9f11b 100644 --- a/src/libWiiPy/ticket.py +++ b/src/libWiiPy/ticket.py @@ -1,7 +1,7 @@ # "ticket.py" from libWiiPy by NinjaCheetah & Contributors # https://github.com/NinjaCheetah/libWiiPy # -# See https://wiibrew.org/wiki/Ticket for details about the TMD format +# See https://wiibrew.org/wiki/Ticket for details about the ticket format import io from .crypto import decrypt_title_key diff --git a/src/libWiiPy/types.py b/src/libWiiPy/types.py index 85dd87b..da78e14 100644 --- a/src/libWiiPy/types.py +++ b/src/libWiiPy/types.py @@ -22,8 +22,8 @@ class ContentRecord: 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 + content_id: int # The unique ID of the content. + index: int # The index of this content in the content record. + content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared. + content_size: int # Size of the content when decrypted. + content_hash: bytes # SHA-1 hash of the content when decrypted.