From 74192f8febb15c4a96f4282877ecd36f6fefd76b Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:48:51 -0500 Subject: [PATCH 01/19] Experimental content re-encrypting code --- src/libWiiPy/content.py | 2 +- src/libWiiPy/crypto.py | 47 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index ebd55dc..f769e2b 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -91,7 +91,7 @@ class ContentRegion: 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! The incorrect Title Key may" + 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())) diff --git a/src/libWiiPy/crypto.py b/src/libWiiPy/crypto.py index a112e2f..9ea1256 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -39,7 +39,8 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes: 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. + This requires the index of the content to decrypt as it is used as the IV, as well as the content length to adjust + padding as necessary. Parameters ---------- @@ -72,3 +73,47 @@ def decrypt_content(content_enc, title_key, content_index, content_length) -> by while len(content_dec) > content_length: content_dec = content_dec[:-1] return content_dec + + +def encrypt_content(content_dec, title_key, content_index) -> bytes: + """Gets the encrypted version of the decrypted content. + + This requires the index of the content to encrypt as it is used as the IV, as well as the content length to adjust + padding as necessary. + + Parameters + ---------- + content_dec : bytes + The decrypted 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. + + 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' + # Align content to 64 bytes to ensure that all the data is being encrypted, and so it works with AES encryption. + bytes_added = None + if (len(content_dec) % 64) != 0: + bytes_added = len(b'\x00' * (64 - (len(content_dec) % 64))) + print(bytes_added) + content_dec = content_dec + (b'\x00' * (64 - (len(content_dec) % 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) + # Encrypt the content using the AES object. + content_enc = aes.encrypt(content_dec) + # Remove any bytes added. + if bytes_added: + while bytes_added: + content_enc = content_enc[:-1] + bytes_added -= 1 + print("removing " + str(bytes_added)) + print(str(len(content_enc))) + return content_enc From 748dbfd6d5df818e35756529f4f449f17b48590e Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Wed, 6 Mar 2024 08:53:03 -0500 Subject: [PATCH 02/19] Update README.md --- README.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e22ceb0..7c8daf7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,24 @@ # libWiiPy -libWiiPy is a port of the project [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), originally created by `Leathl`, now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee), back to Python after it was created by porting [Wii.py](https://github.com/grp/Wii.py) to C#. +libWiiPy is a modern Python 3 library for interacting with and editing files from the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could reasonably do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform. -### Why port this again instead of just updating Wii.py? -This is a really good question. Frankly, a lot of it comes from the fact that I am inexperienced with all of this Wii stuff. Attempting to recreate the features of libWiiSharp in Python with all of the freedom to do that however I see fit makes it a lot easier for someone like me to make this work. The code for Wii.py is also on the older side and is just written in a way that I can't easily understand. It's helpful as a reference here and there, but I mostly want to write this library in a unique way. +libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), originally created by `Leathl`, now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). libWiiSharp is absolutely the way to go if you need a C# library for Wii files. -I also want to package this as a proper PyPI package, and starting with a clean slate will make that a lot easier as well. +**Note:** While libWiiPy is directly inspired by libWiiSharp and aims to have feature parity with it, no code from either libWiiSharp or Wii.py was used in the making of this library. All code is original and is written by [@NinjaCheetah](https://github.com/NinjaCheetah), [@rvtr](https://github.com/rvtr), and any other GitHub contributors. + +# Usage +A wiki, and in the future a potential documenation site, is being worked on, and can be accessed [here](https://github.com/NinjaCheetah/libWiiPy/wiki). It is currently fairly barebones, but it will be improved in the future. + +The easiest way to get libWiiPy for your project is to install the latest version of the library from PyPI, as shown below. +```sh +pip install -U libWiiPy +``` +Our PyPI project page can be found [here](https://pypi.org/project/libWiiPy/). + +Because libWiiPy is very early in development, you may want to use the latest version of the package via git instead, so that you have the latest features available. You can do that like this: +```sh +pip install -U git+https://github.com/NinjaCheetah/libWiiPy +``` +Please be aware that because libWiiPy is in a very early state right now, many features may be subject to change, and methods and properties available now have the potential to disappear in the future. # Building To build this package locally, the steps are quite simple, and should apply to all platforms. Make sure you've set up your `venv` first! @@ -24,12 +38,8 @@ And that's all! You'll find your compiled pip package in `dist/`. # Special Thanks This project wouldn't be possible without the amazing people behind its predecessors and all of the people who have contributed to the documentation of the Wii's inner workings over at [Wiibrew](https://wiibrew.org). -## Special Thanks from libWiiSharp -- Xuzz, SquidMan, megazig, Matt_P, Omega and The Lemon Man for Wii.py -- megazig for his bns conversion code (bns.py) -- SquidMan for Zetsubou -- Arikado and Lunatik for Dop-Mii -- Andre Perrot for gbalzss +## Special Thanks for the Inspiration and Previous Projects +- Xuzz, SquidMan, megazig, Matt_P, Omega and The Lemon Man for creating Wii.py - Leathl for creating libWiiSharp - TheShadowEevee for maintaining libWiiSharp From 230095e19932b58be0a7643d9ed7db0ba05d8b3b Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Wed, 6 Mar 2024 08:57:23 -0500 Subject: [PATCH 03/19] Added dependencies to pyproject.toml --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e09d5a3..090c31a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ authors = [ { name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" }, { name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" } ] -description = "A Wii-related library for Python" +description = "A modern Python library for handling files used by the Wii" readme = "README.md" requires-python = ">=3.10" classifiers = [ @@ -13,6 +13,9 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +dependencies = [ + "pycryptodome", +] [project.urls] Homepage = "https://github.com/NinjaCheetah/libWiiPy" From 67c5a4b59e3116b7df5c8ae58b9773e1ca75a6e9 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Fri, 8 Mar 2024 09:50:27 -0500 Subject: [PATCH 04/19] Added "Features" section to README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 6179834..35fe7ee 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSh **Note:** While libWiiPy is directly inspired by libWiiSharp and aims to have feature parity with it, no code from either libWiiSharp or Wii.py was used in the making of this library. All code is original and is written by [@NinjaCheetah](https://github.com/NinjaCheetah), [@rvtr](https://github.com/rvtr), and any other GitHub contributors. +# Features +This list will expand as libWiiPy is developed, but these features are currently available: +- TMD, ticket, and WAD parsing +- Title Key and content decryption and extraction +- Content encryption and WAD packing + # Usage A wiki, and in the future a potential documenation site, is being worked on, and can be accessed [here](https://github.com/NinjaCheetah/libWiiPy/wiki). It is currently fairly barebones, but it will be improved in the future. From 9598d6d434da5468fd054aabed4e9ac54c4dc186 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Sun, 17 Mar 2024 15:50:57 -0400 Subject: [PATCH 05/19] Content encryption is now working --- src/libWiiPy/__init__.py | 7 +++++++ src/libWiiPy/content.py | 9 +++++---- src/libWiiPy/crypto.py | 16 +++++----------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/libWiiPy/__init__.py b/src/libWiiPy/__init__.py index e69de29..33b8173 100644 --- a/src/libWiiPy/__init__.py +++ b/src/libWiiPy/__init__.py @@ -0,0 +1,7 @@ +# "__init__.py" from libWiiPy by NinjaCheetah & Contributors +# https://github.com/NinjaCheetah/libWiiPy + +from .wad import * +from .tmd import * +from .ticket import * +from .content import * diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index f769e2b..bb3621b 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -91,8 +91,9 @@ class ContentRegion: 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! 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())) + #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())) + print("mismatch idiot") return content_dec diff --git a/src/libWiiPy/crypto.py b/src/libWiiPy/crypto.py index 9ea1256..9b0f61a 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -93,27 +93,21 @@ def encrypt_content(content_dec, title_key, content_index) -> bytes: Returns ------- bytes - The decrypted content. + The encrypted 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' + # Calculate the intended size of the encrypted content. + enc_size = len(content_dec) + (16 - (len(content_dec) % 16)) # Align content to 64 bytes to ensure that all the data is being encrypted, and so it works with AES encryption. - bytes_added = None if (len(content_dec) % 64) != 0: - bytes_added = len(b'\x00' * (64 - (len(content_dec) % 64))) - print(bytes_added) content_dec = content_dec + (b'\x00' * (64 - (len(content_dec) % 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) # Encrypt the content using the AES object. content_enc = aes.encrypt(content_dec) - # Remove any bytes added. - if bytes_added: - while bytes_added: - content_enc = content_enc[:-1] - bytes_added -= 1 - print("removing " + str(bytes_added)) - print(str(len(content_enc))) + # Trim down the encrypted content. + content_enc = content_enc[:enc_size] return content_enc From 0cb37487b6b696ce51c720d7ee548dde23eabf83 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Sun, 17 Mar 2024 23:28:02 -0400 Subject: [PATCH 06/19] WAD extraction now works for all non-vWii WADs! --- src/libWiiPy/shared.py | 22 ++++++++++++++++++++++ src/libWiiPy/wad.py | 15 ++++++++------- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/libWiiPy/shared.py b/src/libWiiPy/shared.py index e69de29..21fcad1 100644 --- a/src/libWiiPy/shared.py +++ b/src/libWiiPy/shared.py @@ -0,0 +1,22 @@ +# "shared.py" from libWiiPy by NinjaCheetah & Contributors +# https://github.com/NinjaCheetah/libWiiPy + +def align_value(value, alignment=64): + """Aligns the provided value to the set alignment (defaults to 64). + + Parameters + ---------- + value : int + The value to align. + alignment : int + The number to align to. Defaults to 64. + + Returns + ------- + int + The aligned value. + """ + if (value % alignment) != 0: + aligned_value = value + (alignment - (value % alignment)) + return aligned_value + return value diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index 2fe3de3..9ed2337 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -5,6 +5,7 @@ import io import binascii +from .shared import align_value class WAD: @@ -64,7 +65,7 @@ class WAD: # WAD content size. wad_data.seek(0x18) self.wad_content_size = int(binascii.hexlify(wad_data.read(4)), 16) - # Publisher of the title contained in the WAD. + # Time/build stamp for the title contained in the WAD. wad_data.seek(0x1c) self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16) # ==================================================================================== @@ -72,12 +73,12 @@ class WAD: # ==================================================================================== self.wad_cert_offset = self.wad_hdr_size # crl isn't ever used, however an entry for its size exists in the header, so its calculated just in case. - self.wad_crl_offset = int(64 * round((self.wad_cert_offset + self.wad_cert_size) / 64)) - self.wad_tik_offset = int(64 * round((self.wad_crl_offset + self.wad_crl_size) / 64)) - self.wad_tmd_offset = int(64 * round((self.wad_tik_offset + self.wad_tik_size) / 64)) - self.wad_content_offset = int(64 * round((self.wad_tmd_offset + self.wad_tmd_size) / 64)) - # meta is also never used, but Nintendo's tools calculate it, so we should too. - self.wad_meta_offset = int(64 * round((self.wad_content_offset + self.wad_content_size) / 64)) + self.wad_crl_offset = align_value(self.wad_cert_offset + self.wad_cert_size) + self.wad_tik_offset = align_value(self.wad_crl_offset + self.wad_crl_size) + self.wad_tmd_offset = align_value(self.wad_tik_offset + self.wad_tik_size) + # meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things. + self.wad_meta_offset = align_value(self.wad_tmd_offset + self.wad_tmd_size) + self.wad_content_offset = align_value(self.wad_meta_offset + self.wad_meta_size) def get_cert_region(self): """Gets the offset and size of the certificate data. From bfe937f58fc14dfc51ebca9e9b3331d68b990041 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Tue, 19 Mar 2024 21:43:18 -0400 Subject: [PATCH 07/19] Add a check to wad.py that ensures the file being loaded is a WAD --- src/libWiiPy/wad.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index 9ed2337..ad454ee 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -39,6 +39,15 @@ class WAD: self.wad_meta_offset: int # Load header data from WAD stream with io.BytesIO(self.wad) as wad_data: + # Read the first 8 bytes of the file to ensure that it's a WAD. This will currently reject boot2 WADs, but + # this tool cannot handle them correctly right now anyway. + wad_data.seek(0x0) + wad_magic_bin = wad_data.read(8) + wad_magic_hex = binascii.hexlify(wad_magic_bin) + wad_magic = str(wad_magic_hex.decode()) + if wad_magic != "0000002049730000": + raise TypeError("This does not appear to be a valid WAD file, or is a boot2 WAD, which is not currently" + "supported by this library.") # ==================================================================================== # Get the sizes of each data region contained within the WAD. # ==================================================================================== From c2b7724fddbbde6213c6e602a151376981e5d953 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Tue, 19 Mar 2024 22:01:50 -0400 Subject: [PATCH 08/19] Add separate methods to content.py for getting all contents vs 1 content by index --- src/libWiiPy/content.py | 43 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index bb3621b..b7fad83 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -42,8 +42,8 @@ class ContentRegion: start_offset += 64 - (content.content_size % 64) 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. + def get_enc_content_by_index(self, index: int) -> bytes: + """Gets an individual content from the content region based on the provided index, in encrypted form. Parameters ---------- @@ -66,8 +66,22 @@ class ContentRegion: content_enc = content_region_data.read(bytes_to_read) 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. + def get_enc_contents(self) -> List[bytes]: + """Gets a list of all encrypted contents from the content region. + + Returns + ------- + List[bytes] + A list containing all encrypted contents. + """ + enc_contents: List[bytes] = [] + # Iterate over every content and add it to a list, then return it. + for content in range(self.num_contents): + enc_contents.append(self.get_enc_content_by_index(content)) + return enc_contents + + def get_content_by_index(self, index: int, title_key: bytes) -> bytes: + """Gets an individual content from the content region based on the provided index, in decrypted form. Parameters ---------- @@ -82,7 +96,7 @@ class ContentRegion: 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_enc = self.get_enc_content_by_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. @@ -97,3 +111,22 @@ class ContentRegion: # "Actual hash is: {}".format(content_dec_hash.hexdigest())) print("mismatch idiot") return content_dec + + def get_contents(self, title_key: bytes) -> List[bytes]: + """Gets a list of all contents from the content region, in decrypted form. + + Parameters + ---------- + title_key : bytes + The Title Key for the title the content is from. + + Returns + ------- + List[bytes] + A list containing all decrypted contents. + """ + dec_contents: List[bytes] = [] + # Iterate over every content, get the decrypted version of it, then add it to a list and return it. + for content in range(self.num_contents): + dec_contents.append(self.get_content_by_index(content, title_key)) + return dec_contents From 7b6703cf3698af394eb7a1a5684ac418763aaa73 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Tue, 19 Mar 2024 22:48:55 -0400 Subject: [PATCH 09/19] Created title.py to handle titles as a whole and allow for changing parts of them more easily --- src/libWiiPy/__init__.py | 8 +++--- src/libWiiPy/ticket.py | 18 +++++++++++++ src/libWiiPy/title.py | 58 ++++++++++++++++++++++++++++++++++++++++ src/libWiiPy/tmd.py | 12 +++++++++ src/libWiiPy/wad.py | 2 +- 5 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/libWiiPy/title.py diff --git a/src/libWiiPy/__init__.py b/src/libWiiPy/__init__.py index 33b8173..7530d43 100644 --- a/src/libWiiPy/__init__.py +++ b/src/libWiiPy/__init__.py @@ -1,7 +1,9 @@ # "__init__.py" from libWiiPy by NinjaCheetah & Contributors # https://github.com/NinjaCheetah/libWiiPy -from .wad import * -from .tmd import * -from .ticket import * +from .commonkeys import * from .content import * +from .ticket import * +from .title import * +from .tmd import * +from .wad import * diff --git a/src/libWiiPy/ticket.py b/src/libWiiPy/ticket.py index 3682d09..982922c 100644 --- a/src/libWiiPy/ticket.py +++ b/src/libWiiPy/ticket.py @@ -4,6 +4,7 @@ # See https://wiibrew.org/wiki/Ticket for details about the ticket format import io +import binascii from .crypto import decrypt_title_key from dataclasses import dataclass from typing import List @@ -66,6 +67,7 @@ class Ticket: self.ticket_id: bytes # Used as the IV when decrypting the title key for console-specific title installs. self.console_id: int # ID of the console that the ticket was issued for. self.title_id: bytes # TID/IV used for AES-CBC encryption. + self.title_id_str: str # TID in string form for comparing against the TMD. self.title_version: int # Version of the ticket's associated title. self.permitted_titles: bytes # Permitted titles mask self.permit_mask: bytes # "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the Permitted Titles Mask." @@ -106,6 +108,9 @@ class Ticket: # Title ID ticket_data.seek(0x1DC) self.title_id = ticket_data.read(8) + # Title ID (as a string) + title_id_hex = binascii.hexlify(self.title_id) + self.title_id_str = str(title_id_hex.decode()) # Title version ticket_data.seek(0x1E6) title_version_high = int.from_bytes(ticket_data.read(1)) * 256 @@ -175,3 +180,16 @@ class Ticket: """ title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id) return title_key + + def set_title_id(self, title_id): + """Sets the Title ID of the title in the Ticket. + + Parameters + ---------- + title_id : str + The new Title ID of the title. + """ + if len(title_id) != 16: + raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.") + self.title_id_str = title_id + self.title_id = binascii.unhexlify(title_id) diff --git a/src/libWiiPy/title.py b/src/libWiiPy/title.py new file mode 100644 index 0000000..1463a08 --- /dev/null +++ b/src/libWiiPy/title.py @@ -0,0 +1,58 @@ +# "title.py" from libWiiPy by NinjaCheetah & Contributors +# https://github.com/NinjaCheetah/libWiiPy +# +# See https://wiibrew.org/wiki/Title for details about how titles are formatted + +from .content import ContentRegion +from .ticket import Ticket +from .tmd import TMD +from .wad import WAD + + +class Title: + """Creates a Title object that contains all components of a title, and allows altering them. + + Parameters + ---------- + wad : WAD + A WAD object to load data from. + + Attributes + ---------- + tmd : TMD + A TMD object of the title's TMD. + ticket : Ticket + A Ticket object of the title's Ticket. + content: ContentRegion + A ContentRegion object containing the title's contents. + """ + def __init__(self, wad: WAD): + self.wad = wad + self.tmd: TMD + self.ticket: Ticket + self.content: ContentRegion + # Load data from the WAD object, and generate all other objects from the data in it. + # Load the TMD. + self.tmd = TMD(self.wad.get_tmd_data()) + # Load the ticket. + self.ticket = Ticket(self.wad.get_ticket_data()) + # Load the content. + self.content = ContentRegion(self.wad.get_content_data(), self.tmd.content_records) + # Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an + # error because clearly something strange has gone on with the WAD and editing it probably won't work. + if self.tmd.title_id != self.ticket.title_id_str: + raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be " + "invalid.") + + def set_title_id(self, title_id: str): + """Sets the Title ID of the title in both the TMD and Ticket. + + Parameters + ---------- + title_id : str + The new Title ID of the title. + """ + if len(title_id) != 16: + raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.") + self.tmd.set_title_id(title_id) + self.ticket.set_title_id(title_id) diff --git a/src/libWiiPy/tmd.py b/src/libWiiPy/tmd.py index a033706..bb82d2b 100644 --- a/src/libWiiPy/tmd.py +++ b/src/libWiiPy/tmd.py @@ -235,3 +235,15 @@ class TMD: else: raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) + "' contents but index was '" + str(record) + "'!") + + def set_title_id(self, title_id): + """Sets the Title ID of the title in the ticket. + + Parameters + ---------- + title_id : str + The new Title ID of the title. + """ + if len(title_id) != 16: + raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.") + self.title_id = title_id diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index ad454ee..3b648c1 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -47,7 +47,7 @@ class WAD: wad_magic = str(wad_magic_hex.decode()) if wad_magic != "0000002049730000": raise TypeError("This does not appear to be a valid WAD file, or is a boot2 WAD, which is not currently" - "supported by this library.") + " supported by this library.") # ==================================================================================== # Get the sizes of each data region contained within the WAD. # ==================================================================================== From 8244d79fbaa77ab51d6d42868138a715117a5daa Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Wed, 20 Mar 2024 23:11:09 -0400 Subject: [PATCH 10/19] wad.py and tmd.py now support dumping the object back into raw data --- src/libWiiPy/shared.py | 20 +++++ src/libWiiPy/tmd.py | 170 +++++++++++++++++++++++++++++++---------- src/libWiiPy/wad.py | 98 +++++++++++++++++++----- 3 files changed, 232 insertions(+), 56 deletions(-) diff --git a/src/libWiiPy/shared.py b/src/libWiiPy/shared.py index 21fcad1..e2ed428 100644 --- a/src/libWiiPy/shared.py +++ b/src/libWiiPy/shared.py @@ -20,3 +20,23 @@ def align_value(value, alignment=64): aligned_value = value + (alignment - (value % alignment)) return aligned_value return value + + +def pad_bytes_stream(data, alignment=64): + """Pads the provided bytes stream to the provided alignment (defaults to 64). + + Parameters + ---------- + data : BytesIO + The data to align. + alignment : int + The number to align to. Defaults to 64. + + Returns + ------- + BytesIO + The aligned data. + """ + while (data.getbuffer().nbytes % alignment) != 0: + data.write(b'\x00') + return data diff --git a/src/libWiiPy/tmd.py b/src/libWiiPy/tmd.py index bb82d2b..f01e6fa 100644 --- a/src/libWiiPy/tmd.py +++ b/src/libWiiPy/tmd.py @@ -36,87 +36,103 @@ class TMD: """ def __init__(self, tmd): self.tmd = tmd - self.sig_type: int - self.sig: bytearray - self.issuer: bytearray # Follows the format "Root-CA%08x-CP%08x" - self.tmd_version: int # This seems to always be 0 no matter what? - self.ca_crl_version: int - self.signer_crl_version: int - self.vwii: int # Whether the title is for the vWii. 0 = No, 1 = Yes - self.ios_tid: str # The Title ID of the IOS version the associated title runs on. - self.ios_version: int # The IOS version the associated title runs on. - self.title_id: str # The Title ID of the associated title. - self.content_type: str # The type of content contained within the associated title. - self.group_id: int # The ID of the publisher of the associated title. - self.region: int # The ID of the region of the associated title. - self.ratings: int - self.access_rights: int - self.title_version: int # The version of the associated title. - self.num_contents: int # The number of contents contained in the associated title. - self.boot_index: int + self.blob_header: bytes = b'' + self.sig_type: int = 0 + self.sig: bytes = b'' + self.issuer: bytes = b'' # Follows the format "Root-CA%08x-CP%08x" + self.tmd_version: int = 0 # This seems to always be 0 no matter what? + self.ca_crl_version: int = 0 + self.signer_crl_version: int = 0 + self.vwii: int = 0 # Whether the title is for the vWii. 0 = No, 1 = Yes + self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on. + self.ios_version: int = 0 # The IOS version the associated title runs on. + self.title_id: str = "" # The Title ID of the associated title. + self.content_type: str = "" # The type of content contained within the associated title. + self.group_id: int = 0 # The ID of the publisher of the associated title. + self.region: int = 0 # The ID of the region of the associated title. + self.ratings: bytes = b'' + self.ipc_mask: bytes = b'' + self.access_rights: bytes = b'' + self.title_version: int = 0 # The version of the associated title. + self.num_contents: int = 0 # The number of contents contained in the associated title. + self.boot_index: int = 0 self.content_records: List[ContentRecord] = [] - # Load data from TMD file + # Call load() to set all of the attributes from the raw TMD data provided. + self.load() + + def load(self): + """Loads the raw TMD data and sets all attributes of the TMD object. + + Returns + ------- + none + """ with io.BytesIO(self.tmd) as tmd_data: # ==================================================================================== # Parses each of the keys contained in the TMD. # ==================================================================================== - # Signing certificate issuer + tmd_data.seek(0x0) + self.blob_header = tmd_data.read(320) + # Signing certificate issuer. 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 + # TMD version, seems to usually be 0, but I've seen references to other numbers. tmd_data.seek(0x180) self.tmd_version = int.from_bytes(tmd_data.read(1)) - # TODO: label + # Root certificate crl version. tmd_data.seek(0x181) - self.ca_crl_version = tmd_data.read(1) - # TODO: label + self.ca_crl_version = int.from_bytes(tmd_data.read(1)) + # Signer crl version. tmd_data.seek(0x182) - self.signer_crl_version = tmd_data.read(1) - # If this is a vWii title or not + self.signer_crl_version = int.from_bytes(tmd_data.read(1)) + # If this is a vWii title or not. 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 + # TID of the IOS to use for the title, set to 0 if this title is the IOS, set to boot2 version if boot2. 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 + # Get IOS version based on TID. self.ios_version = int(self.ios_tid[-2:], 16) - # Title ID of the title + # Title ID of the title. 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 + # Type of content. 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 + # Publisher of the title. 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 + self.group_id = int.from_bytes(tmd_data.read(2)) + # Region of the title, 0 = JAP, 1 = USA, 2 = EUR, 3 = NONE, 4 = KOR. tmd_data.seek(0x19C) region_hex = tmd_data.read(2) self.region = int.from_bytes(region_hex) - # TODO: figure this one out + # Likely the localized content rating for the title. (ESRB, CERO, PEGI, etc.) tmd_data.seek(0x19E) self.ratings = tmd_data.read(16) - # Access rights of the title; DVD-video access and AHBPROT + # IPC mask. + tmd_data.seek(0x1BA) + self.ipc_mask = tmd_data.read(12) + # Access rights of the title; DVD-video access and AHBPROT. tmd_data.seek(0x1D8) self.access_rights = tmd_data.read(4) - # Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD + # Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD. 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 + # The number of contents listed in the TMD. tmd_data.seek(0x1DE) self.num_contents = int.from_bytes(tmd_data.read(2)) - # Content index in content list that contains the boot file + # Content index in content list that contains the boot file. tmd_data.seek(0x1E0) - self.boot_index = tmd_data.read(2) + self.boot_index = int.from_bytes(tmd_data.read(2)) # Get content records for the number of contents in num_contents. for content in range(0, self.num_contents): tmd_data.seek(0x1E4 + (36 * content)) @@ -126,6 +142,82 @@ class TMD: int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]), binascii.hexlify(content_record_hdr[4]))) + def dump(self) -> bytes: + """Dumps the TMD object back into bytes. This also sets the raw TMD attribute of TMD object to the dumped data, + and triggers load() again to ensure that the raw data and object match. + + Returns + ------- + bytes + The full TMD file as bytes. + """ + # Open the stream and begin writing to it. + with io.BytesIO() as tmd_data: + # Signed blob header. + tmd_data.write(self.blob_header) + # Signing certificate issuer. + tmd_data.write(self.issuer) + # TMD version. + tmd_data.write(int.to_bytes(self.tmd_version, 1)) + # Root certificate crl version. + tmd_data.write(int.to_bytes(self.ca_crl_version, 1)) + # Signer crl version. + tmd_data.write(int.to_bytes(self.signer_crl_version, 1)) + # If this is a vWii title or not. + tmd_data.write(int.to_bytes(self.vwii, 1)) + # IOS Title ID. + tmd_data.write(binascii.unhexlify(self.ios_tid)) + # Title's Title ID. + tmd_data.write(binascii.unhexlify(self.title_id)) + # Content type. + tmd_data.write(binascii.unhexlify(self.content_type)) + # Group ID. + tmd_data.write(int.to_bytes(self.group_id, 2)) + # 2 bytes of zero for reasons. + tmd_data.write(b'\x00\x00') + # Region. + tmd_data.write(int.to_bytes(self.region, 2)) + # Ratings. + tmd_data.write(self.ratings) + # Reserved (All \x00). + tmd_data.write(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') + # IPC mask. + tmd_data.write(self.ipc_mask) + # Reserved (ALl \x00). + tmd_data.write(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') + # Access rights. + tmd_data.write(self.access_rights) + # Title version. + title_version_high = round(self.title_version / 256) + tmd_data.write(int.to_bytes(title_version_high, 1)) + title_version_low = self.title_version % 256 + tmd_data.write(int.to_bytes(title_version_low, 1)) + # Number of contents. + tmd_data.write(int.to_bytes(self.num_contents, 2)) + # Boot index. + tmd_data.write(int.to_bytes(self.boot_index, 2)) + # Minor version. Unused so write \x00. + tmd_data.write(b'\x00\x00') + # Iterate over content records, write them back into raw data, then add them to the TMD. + for content_record in range(self.num_contents): + content_data = io.BytesIO() + # Write all fields from the content record. + content_data.write(int.to_bytes(self.content_records[content_record].content_id, 4)) + content_data.write(int.to_bytes(self.content_records[content_record].index, 2)) + content_data.write(int.to_bytes(self.content_records[content_record].content_type, 2)) + content_data.write(int.to_bytes(self.content_records[content_record].content_size, 8)) + content_data.write(binascii.unhexlify(self.content_records[content_record].content_hash)) + # Seek to the start and write the record to the TMD. + content_data.seek(0x0) + tmd_data.write(content_data.read()) + content_data.close() + + tmd_data.seek(0x0) + self.tmd = tmd_data.read() + # Reload object's attributes to ensure the raw data and object match. + self.load() + return self.tmd + def get_title_region(self): """Gets the region of the TMD's associated title. diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index 3b648c1..b72f52f 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -5,7 +5,7 @@ import io import binascii -from .shared import align_value +from .shared import align_value, pad_bytes_stream class WAD: @@ -19,25 +19,34 @@ class WAD: """ def __init__(self, wad): self.wad = wad - self.wad_hdr_size: int - self.wad_type: str - self.wad_version: int + self.wad_hdr_size: int = 64 + self.wad_type: str = "" + self.wad_version: bytes = b'' # === Sizes === - self.wad_cert_size: int - self.wad_crl_size: int - self.wad_tik_size: int - self.wad_tmd_size: int + self.wad_cert_size: int = 0 + self.wad_crl_size: int = 0 + self.wad_tik_size: int = 0 + self.wad_tmd_size: int = 0 # This is the size of the content region, which contains all app files combined. - self.wad_content_size: int - self.wad_meta_size: int + self.wad_content_size: int = 0 + self.wad_meta_size: int = 0 # === Offsets === - self.wad_cert_offset: int - self.wad_crl_offset: int - self.wad_tik_offset: int - self.wad_tmd_offset: int - self.wad_content_offset: int - self.wad_meta_offset: int - # Load header data from WAD stream + self.wad_cert_offset: int = 0 + self.wad_crl_offset: int = 0 + self.wad_tik_offset: int = 0 + self.wad_tmd_offset: int = 0 + self.wad_content_offset: int = 0 + self.wad_meta_offset: int = 0 + # Call load() to set all of the attributes from the raw WAD data provided. + self.load() + + def load(self): + """Loads the raw WAD data and sets all attributes of the WAD object. + + Returns + ------- + none + """ with io.BytesIO(self.wad) as wad_data: # Read the first 8 bytes of the file to ensure that it's a WAD. This will currently reject boot2 WADs, but # this tool cannot handle them correctly right now anyway. @@ -89,6 +98,61 @@ class WAD: self.wad_meta_offset = align_value(self.wad_tmd_offset + self.wad_tmd_size) self.wad_content_offset = align_value(self.wad_meta_offset + self.wad_meta_size) + def dump(self) -> bytes: + """Dumps the WAD object back into bytes. This also sets the raw WAD attribute of WAD object to the dumped data, + and triggers load() again to ensure that the raw data and object match. + + Returns + ------- + bytes + The full WAD file as bytes. + """ + # Open the stream and begin writing data to it. + with io.BytesIO() as wad_data: + # Lead-in data. + wad_data.write(b'\x00\x00\x00\x20') + # WAD type. + wad_data.write(str.encode(self.wad_type)) + # WAD version. + wad_data.write(self.wad_version) + # WAD cert size. + wad_data.write(int.to_bytes(self.wad_cert_size, 4)) + # WAD crl size. + wad_data.write(int.to_bytes(self.wad_crl_size, 4)) + # WAD ticket size. + wad_data.write(int.to_bytes(self.wad_tik_size, 4)) + # WAD TMD size. + wad_data.write(int.to_bytes(self.wad_tmd_size, 4)) + # WAD content size. + wad_data.write(int.to_bytes(self.wad_content_size, 4)) + # WAD meta size. + wad_data.write(int.to_bytes(self.wad_meta_size, 4)) + wad_data = pad_bytes_stream(wad_data) + # Retrieve the cert data and write it out. + wad_data.write(self.get_cert_data()) + wad_data = pad_bytes_stream(wad_data) + # Retrieve the crl data and write it out. + wad_data.write(self.get_crl_data()) + wad_data = pad_bytes_stream(wad_data) + # Retrieve the ticket data and write it out. + wad_data.write(self.get_ticket_data()) + wad_data = pad_bytes_stream(wad_data) + # Retrieve the TMD data and write it out. + wad_data.write(self.get_tmd_data()) + wad_data = pad_bytes_stream(wad_data) + # Retrieve the meta/footer data and write it out. + wad_data.write(self.get_meta_data()) + wad_data = pad_bytes_stream(wad_data) + # Retrieve the content data and write it out. + wad_data.write(self.get_content_data()) + wad_data = pad_bytes_stream(wad_data) + # Seek to the beginning and save this as the WAD data for the object. + wad_data.seek(0x0) + self.wad = wad_data.read() + # Reload object's attributes to ensure the raw data and object match. + self.load() + return self.wad + def get_cert_region(self): """Gets the offset and size of the certificate data. From b5aab5ad22c85e86e29e2e998d18acd58d65110b Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Thu, 21 Mar 2024 21:09:46 -0400 Subject: [PATCH 11/19] Added ability to dump tickets back to raw data --- src/libWiiPy/ticket.py | 183 +++++++++++++++++++++++++++++------------ src/libWiiPy/tmd.py | 10 +-- src/libWiiPy/types.py | 19 +++++ 3 files changed, 153 insertions(+), 59 deletions(-) diff --git a/src/libWiiPy/ticket.py b/src/libWiiPy/ticket.py index 982922c..c61a2a5 100644 --- a/src/libWiiPy/ticket.py +++ b/src/libWiiPy/ticket.py @@ -6,29 +6,10 @@ import io import binascii from .crypto import decrypt_title_key -from dataclasses import dataclass +from .types import TitleLimit from typing import List -@dataclass -class TitleLimit: - """Creates a TitleLimit object that contains the type of restriction and the limit. - - Attributes - ---------- - limit_type : int - The type of play limit applied. - maximum_usage : int - The maximum value for the type of play limit applied. - """ - # The type of play limit applied. The following types exist: - # 0 = None, 1 = Time Limit, 3 = None, 4 = Launch Count - limit_type: int - # The maximum value of the limit applied. - # This is either the number of minutes for a time limit, or the number of launches for a launch limit. - maximum_usage: int - - class Ticket: """Creates a Ticket object to parse a Ticket file to retrieve the Title Key needed to decrypt it. @@ -57,88 +38,182 @@ class Ticket: def __init__(self, ticket): self.ticket = ticket # Signature blob header - self.signature_type: bytes # Type of signature, always 0x10001 for RSA-2048 - self.signature: bytes # Actual signature data + self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048 + self.signature: bytes = b'' # Actual signature data # v0 ticket data - self.signature_issuer: str # Who issued the signature for the ticket - self.ecdh_data: bytes # Involved in created one-time keys for console-specific title installs. - self.ticket_version: int # The version of the current ticket file. - self.title_key_enc: bytes # The title key of the ticket's respective title, encrypted by a common key. - self.ticket_id: bytes # Used as the IV when decrypting the title key for console-specific title installs. - self.console_id: int # ID of the console that the ticket was issued for. - self.title_id: bytes # TID/IV used for AES-CBC encryption. - self.title_id_str: str # TID in string form for comparing against the TMD. - self.title_version: int # Version of the ticket's associated title. - self.permitted_titles: bytes # Permitted titles mask - self.permit_mask: bytes # "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the Permitted Titles Mask." - self.title_export_allowed: int # Whether title export is allowed with a PRNG key or not. - self.common_key_index: int # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key - self.content_access_permissions: bytes # "Content access permissions (one bit for each content)" + self.signature_issuer: str = "" # Who issued the signature for the ticket + self.ecdh_data: bytes = b'' # Involved in created one-time keys for console-specific title installs. + self.ticket_version: int = 0 # The version of the current ticket file. + self.title_key_enc: bytes = b'' # The title key of the ticket's respective title, encrypted by a common key. + self.ticket_id: bytes = b'' # Used as the IV when decrypting the title key for console-specific title installs. + self.console_id: int = 0 # ID of the console that the ticket was issued for. + self.title_id: bytes = b'' # TID/IV used for AES-CBC encryption. + self.title_id_str: str = "" # TID in string form for comparing against the TMD. + self.unknown1: bytes = b'' # Some unknown data, not always the same so reading it just in case. + self.title_version: int = 0 # Version of the ticket's associated title. + self.permitted_titles: bytes = b'' # Permitted titles mask + self.permit_mask: bytes = b'' # "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the Permitted Titles Mask." + self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not. + self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key + self.unknown2: bytes = b'' # More unknown data. Varies for VC/non-VC titles so reading it to ensure it matches. + self.content_access_permissions: bytes = b'' # "Content access permissions (one bit for each content)" self.title_limits_list: List[TitleLimit] = [] # List of play limits applied to the title. # v1 ticket data - # TODO: Figure out v1 ticket stuff + # TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1. + # Call load() to set all of the attributes from the raw Ticket data provided. + self.load() + + def load(self): + """Loads the raw Ticket data and sets all attributes of the Ticket object. + + Returns + ------- + none + """ with io.BytesIO(self.ticket) as ticket_data: # ==================================================================================== # Parses each of the keys contained in the Ticket. # ==================================================================================== - # Signature type + # Signature type. ticket_data.seek(0x0) self.signature_type = ticket_data.read(4) - # Signature data + # Signature data. ticket_data.seek(0x04) self.signature = ticket_data.read(256) - # Signature issuer + # Signature issuer. ticket_data.seek(0x140) self.signature_issuer = str(ticket_data.read(64).decode()) - # ECDH data + # ECDH data. ticket_data.seek(0x180) self.ecdh_data = ticket_data.read(60) - # Ticket version + # Ticket version. ticket_data.seek(0x1BC) self.ticket_version = int.from_bytes(ticket_data.read(1)) - # Title Key (Encrypted by a common key) + if self.ticket_version == 1: + raise ValueError("This appears to be a v1 ticket, which is not currently supported by libWiiPy. This " + "feature is planned for a later release. Only v0 tickets are supported at this time.") + # Title Key (Encrypted by a common key). ticket_data.seek(0x1BF) self.title_key_enc = ticket_data.read(16) - # Ticket ID + # Ticket ID. ticket_data.seek(0x1D0) self.ticket_id = ticket_data.read(8) - # Console ID + # Console ID. ticket_data.seek(0x1D8) self.console_id = int.from_bytes(ticket_data.read(4)) - # Title ID + # Title ID. ticket_data.seek(0x1DC) self.title_id = ticket_data.read(8) - # Title ID (as a string) + # Title ID (as a string). title_id_hex = binascii.hexlify(self.title_id) self.title_id_str = str(title_id_hex.decode()) - # Title version + # Unknown data 1. + ticket_data.seek(0x1E4) + self.unknown1 = ticket_data.read(2) + # Title version. 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 + # Permitted titles mask. ticket_data.seek(0x1E8) self.permitted_titles = ticket_data.read(4) - # Permit mask + # Permit mask. ticket_data.seek(0x1EC) self.permit_mask = ticket_data.read(4) - # Whether title export with a PRNG key is allowed + # Whether title export with a PRNG key is allowed. ticket_data.seek(0x1F0) self.title_export_allowed = int.from_bytes(ticket_data.read(1)) - # Common key index + # Common key index. ticket_data.seek(0x1F1) self.common_key_index = int.from_bytes(ticket_data.read(1)) - # Content access permissions + # Unknown data 2. + ticket_data.seek(0x1F2) + self.unknown2 = ticket_data.read(48) + # Content access permissions. ticket_data.seek(0x222) self.content_access_permissions = ticket_data.read(64) - # Content limits + # Content limits. ticket_data.seek(0x264) for limit in range(0, 8): 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 dump(self) -> bytes: + """Dumps the Ticket object back into bytes. This also sets the raw Ticket attribute of Ticket object to the + dumped data, and triggers load() again to ensure that the raw data and object match. + + Returns + ------- + bytes + The full Ticket file as bytes. + """ + # Open the stream and begin writing to it. + with io.BytesIO() as ticket_data: + # Signature type. + ticket_data.write(self.signature_type) + # Signature data. + ticket_data.write(self.signature) + # Padding to 64 bytes. + ticket_data.write(b'\x00' * 60) + # Signature issuer. + ticket_data.write(str.encode(self.signature_issuer)) + # ECDH data. + ticket_data.write(self.ecdh_data) + # Ticket version. + ticket_data.write(int.to_bytes(self.ticket_version, 1)) + # Reserved (all \0x00). + ticket_data.write(b'\x00\x00') + # Title Key. + ticket_data.write(self.title_key_enc) + # Unknown (write \0x00). + ticket_data.write(b'\x00') + # Ticket ID. + ticket_data.write(self.ticket_id) + # Console ID. + ticket_data.write(int.to_bytes(self.console_id, 4)) + # Title ID. + ticket_data.write(self.title_id) + # Unknown data 1. + ticket_data.write(self.unknown1) + # Title version. + title_version_high = round(self.title_version / 256) + ticket_data.write(int.to_bytes(title_version_high, 1)) + title_version_low = self.title_version % 256 + ticket_data.write(int.to_bytes(title_version_low, 1)) + # Permitted titles mask. + ticket_data.write(self.permitted_titles) + # Permit mask. + ticket_data.write(self.permit_mask) + # Title Export allowed. + ticket_data.write(int.to_bytes(self.title_export_allowed, 1)) + # Common Key index. + ticket_data.write(int.to_bytes(self.common_key_index, 1)) + # Unknown data 2. + ticket_data.write(self.unknown2) + # Content access permissions. + ticket_data.write(self.content_access_permissions) + # Padding (always \x00). + ticket_data.write(b'\x00\x00') + # Iterate over Title Limit objects, write them back into raw data, then add them to the Ticket. + for title_limit in range(len(self.title_limits_list)): + title_limit_data = io.BytesIO() + # Write all fields from the title limit entry. + title_limit_data.write(int.to_bytes(self.title_limits_list[title_limit].limit_type, 4)) + title_limit_data.write(int.to_bytes(self.title_limits_list[title_limit].maximum_usage, 4)) + # Seek to the start and write the entry to the Ticket. + title_limit_data.seek(0x0) + ticket_data.write(title_limit_data.read()) + title_limit_data.close() + # Set the Ticket attribute of the object to the new raw Ticket. + ticket_data.seek(0x0) + self.ticket = ticket_data.read() + # Reload object's attributes to ensure the raw data and object match. + self.load() + return self.ticket + def get_title_id(self): """Gets the Title ID of the ticket's associated title. diff --git a/src/libWiiPy/tmd.py b/src/libWiiPy/tmd.py index f01e6fa..0f6bbcb 100644 --- a/src/libWiiPy/tmd.py +++ b/src/libWiiPy/tmd.py @@ -179,12 +179,12 @@ class TMD: tmd_data.write(int.to_bytes(self.region, 2)) # Ratings. tmd_data.write(self.ratings) - # Reserved (All \x00). - tmd_data.write(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') + # Reserved (all \x00). + tmd_data.write(b'\x00' * 12) # IPC mask. tmd_data.write(self.ipc_mask) - # Reserved (ALl \x00). - tmd_data.write(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') + # Reserved (all \x00). + tmd_data.write(b'\x00' * 18) # Access rights. tmd_data.write(self.access_rights) # Title version. @@ -211,7 +211,7 @@ class TMD: content_data.seek(0x0) tmd_data.write(content_data.read()) content_data.close() - + # Set the TMD attribute of the object to the new raw TMD. tmd_data.seek(0x0) self.tmd = tmd_data.read() # Reload object's attributes to ensure the raw data and object match. diff --git a/src/libWiiPy/types.py b/src/libWiiPy/types.py index fc0a57e..3a9f0ff 100644 --- a/src/libWiiPy/types.py +++ b/src/libWiiPy/types.py @@ -27,3 +27,22 @@ class ContentRecord: 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. + + +@dataclass +class TitleLimit: + """Creates a TitleLimit object that contains the type of restriction and the limit. + + Attributes + ---------- + limit_type : int + The type of play limit applied. + maximum_usage : int + The maximum value for the type of play limit applied. + """ + # The type of play limit applied. The following types exist: + # 0 = None, 1 = Time Limit, 3 = None, 4 = Launch Count + limit_type: int + # The maximum value of the limit applied. + # This is either the number of minutes for a time limit, or the number of launches for a launch limit. + maximum_usage: int From 379359c089eda29a4faa9bc43910db07523ff906 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Fri, 29 Mar 2024 20:35:44 -0400 Subject: [PATCH 12/19] Added methods for getting enc and dec content by their Content IDs --- src/libWiiPy/content.py | 63 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index b7fad83..1eb24b3 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -66,6 +66,32 @@ class ContentRegion: content_enc = content_region_data.read(bytes_to_read) return content_enc + def get_enc_content_by_cid(self, cid: int) -> bytes: + """Gets an individual content from the content region based on the provided Content ID, in encrypted form. + + Parameters + ---------- + cid : int + The Content ID of the content you want to get. Expected to be in decimal form. + + Returns + ------- + bytes + The encrypted content listed in the content record. + """ + # Find the index of the requested Content ID. + content_index = None + for content in self.content_records: + if content.content_id == cid: + content_index = content.index + # If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so + # return a Value Error. + if content_index is None: + raise ValueError("The Content ID requested does not exist in the TMD's content records.") + # Call get_enc_content_by_index() using the index we just found. + content_enc = self.get_enc_content_by_index(content_index) + return content_enc + def get_enc_contents(self) -> List[bytes]: """Gets a list of all encrypted contents from the content region. @@ -105,11 +131,38 @@ class ContentRegion: 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! 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())) - print("mismatch idiot") + 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 + + def get_content_by_cid(self, cid: int, title_key: bytes) -> bytes: + """Gets an individual content from the content region based on the provided Content ID, in decrypted form. + + Parameters + ---------- + cid : int + The Content ID of the content you want to get. Expected to be in decimal form. + title_key : bytes + The Title Key for the title the content is from. + + Returns + ------- + bytes + The decrypted content listed in the content record. + """ + # Find the index of the requested Content ID. + content_index = None + for content in self.content_records: + if content.content_id == cid: + content_index = content.index + # If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so + # return a Value Error. + if content_index is None: + raise ValueError("The Content ID requested does not exist in the TMD's content records.") + # Call get_content_by_index() using the index we just found. + content_dec = self.get_content_by_index(content_index, title_key) return content_dec def get_contents(self, title_key: bytes) -> List[bytes]: From 8026fc4fa3342101f4aaac5e6f0fb70f9a1fe664 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Fri, 29 Mar 2024 23:40:13 -0400 Subject: [PATCH 13/19] Added methods to set content in both enc and dec form to content.py --- src/libWiiPy/content.py | 146 ++++++++++++++++++++++++++++++++++------ src/libWiiPy/title.py | 60 ++++++++++++++++- src/libWiiPy/tmd.py | 3 + 3 files changed, 189 insertions(+), 20 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index 1eb24b3..64d2340 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -8,7 +8,7 @@ import sys import hashlib from typing import List from .types import ContentRecord -from .crypto import decrypt_content +from .crypto import decrypt_content, encrypt_content class ContentRegion: @@ -25,11 +25,21 @@ class ContentRegion: 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_region_size: int = 0 # Size of the content region. + self.num_contents: int = 0 # Number of contents in the content region. self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region. + self.content_list: List[bytes] = [] + # Call load() to set all of the attributes from the raw content region provided. + self.load() - with io.BytesIO(content_region) as content_region_data: + def load(self): + """Loads the raw content region and builds a list of all the contents. + + Returns + ------- + none + """ + with io.BytesIO(self.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) @@ -41,6 +51,48 @@ class ContentRegion: if (content.content_size % 64) != 0: start_offset += 64 - (content.content_size % 64) self.content_start_offsets.append(start_offset) + # Build a list of all the encrypted content data. + for content in range(len(self.content_start_offsets)): + # Seek to the start of the content based on the list of offsets. + content_region_data.seek(self.content_start_offsets[content]) + # 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[content].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, then append that data to + # the list of content. + content_enc = content_region_data.read(bytes_to_read) + self.content_list.append(content_enc) + + def dump(self) -> bytes: + """Takes the list of contents and assembles them back into one content region. Returns this content region as a + bytes object and sets the raw content region variable to this result, then calls load() again to make sure the + content list matches the raw data. + + Returns + ------- + bytes + The full WAD file as bytes. + """ + # Open the stream and begin writing data to it. + with io.BytesIO() as content_region_data: + for content in self.content_list: + # Calculate padding after this content before the next one. + padding_bytes = 0 + if (len(content) % 64) != 0: + padding_bytes = 64 - (len(content) % 64) + # Write content data, then the padding afterward if necessary. + content_region_data.write(content) + if padding_bytes > 0: + content_region_data.write(b'\x00' * padding_bytes) + content_region_data.seek(0x0) + self.content_region = content_region_data.read() + # Clear existing lists. + self.content_start_offsets = [0] + self.content_list = [] + # Reload object's attributes to ensure the raw data and object match. + self.load() + return self.content_region def get_enc_content_by_index(self, index: int) -> bytes: """Gets an individual content from the content region based on the provided index, in encrypted form. @@ -55,16 +107,8 @@ class ContentRegion: 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]) - # 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 + content_enc = self.content_list[index] + return content_enc def get_enc_content_by_cid(self, cid: int) -> bytes: """Gets an individual content from the content region based on the provided Content ID, in encrypted form. @@ -100,11 +144,7 @@ class ContentRegion: List[bytes] A list containing all encrypted contents. """ - enc_contents: List[bytes] = [] - # Iterate over every content and add it to a list, then return it. - for content in range(self.num_contents): - enc_contents.append(self.get_enc_content_by_index(content)) - return enc_contents + return self.content_list def get_content_by_index(self, index: int, title_key: bytes) -> bytes: """Gets an individual content from the content region based on the provided index, in decrypted form. @@ -183,3 +223,71 @@ class ContentRegion: for content in range(self.num_contents): dec_contents.append(self.get_content_by_index(content, title_key)) return dec_contents + + def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int, + content_hash: bytes) -> None: + """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + set in the content record, with a new record being added if necessary. + + Parameters + ---------- + enc_content : bytes + The new encrypted content to set. + cid : int + The Content ID to assign the new content in the content record. + index : int + The index to place the new content at. + content_type : int + The type of the new content. + content_size : int + The size of the new encrypted content when decrypted. + content_hash : bytes + The hash of the new encrypted content when decrypted. + """ + # Save the number of contents currently in the content region and records. + num_contents = len(self.content_records) + # Check if a record already exists for this index. If it doesn't, create it. + if (index + 1) > num_contents: + # Ensure that you aren't attempting to create a gap before appending. + if (index + 1) > num_contents + 1: + raise ValueError("You are trying to set the content at position " + str(index) + ", but no content " + "exists at position " + str(index - 1) + "!") + self.content_records.append(ContentRecord(cid, index, content_type, content_size, content_hash)) + # If it does, reassign the values in it. + else: + self.content_records[index].content_id = cid + self.content_records[index].content_type = content_type + self.content_records[index].content_size = content_size + self.content_records[index].content_hash = content_hash + # Check if a content already occupies the provided index. If it does, reassign it to the new content, if it + # doesn't, then append a new entry. + if (index + 1) > num_contents: + self.content_list.append(enc_content) + else: + self.content_list[index] = enc_content + + def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None: + """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + set in the content record, with a new record being added if necessary. + + Parameters + ---------- + dec_content : bytes + The new decrypted content to set. + cid : int + The Content ID to assign the new content in the content record. + index : int + The index to place the new content at. + content_type : int + The type of the new content. + title_key : bytes + The Title Key that matches the new decrypted content. + """ + # Store the size of the new content. + dec_content_size = len(dec_content) + # Calculate the hash of the new content. + dec_content_hash = str.encode(hashlib.sha1(dec_content).hexdigest()) + # Encrypt the content using the provided Title Key and index. + enc_content = encrypt_content(dec_content, title_key, index) + # Pass values to set_enc_content() + self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash) diff --git a/src/libWiiPy/title.py b/src/libWiiPy/title.py index 1463a08..69288d8 100644 --- a/src/libWiiPy/title.py +++ b/src/libWiiPy/title.py @@ -44,7 +44,18 @@ class Title: raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be " "invalid.") - def set_title_id(self, title_id: str): + def dump(self) -> bytes: + """Dumps all title components (TMD, ticket, and content) back into the WAD object, and then dumps the WAD back + into raw data and returns it. + + Returns + ------- + wad_data : bytes + The raw data of the WAD. + """ + # Dump the TMD. + + def set_title_id(self, title_id: str) -> None: """Sets the Title ID of the title in both the TMD and Ticket. Parameters @@ -56,3 +67,50 @@ class Title: raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.") self.tmd.set_title_id(title_id) self.ticket.set_title_id(title_id) + + def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int, + content_hash: bytes) -> None: + """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + set in the content record, with a new record being added if necessary. The TMD is also updated to match the new + records. + + Parameters + ---------- + enc_content : bytes + The new encrypted content to set. + cid : int + The Content ID to assign the new content in the content record. + index : int + The index to place the new content at. + content_type : int + The type of the new content. + content_size : int + The size of the new encrypted content when decrypted. + content_hash : bytes + The hash of the new encrypted content when decrypted. + """ + # Set the encrypted content. + self.content.set_enc_content(enc_content, cid, index, content_type, content_size, content_hash) + # Update the TMD to match. + self.tmd.content_records = self.content.content_records + + def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int) -> None: + """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + set in the content record, with a new record being added if necessary. The Title Key is sourced from this + title's loaded ticket. The TMD is also updated to match the new records. + + Parameters + ---------- + dec_content : bytes + The new decrypted content to set. + cid : int + The Content ID to assign the new content in the content record. + index : int + The index to place the new content at. + content_type : int + The type of the new content. + """ + # Set the decrypted content. + self.content.set_content(dec_content, cid, index, content_type, self.ticket.get_title_key()) + # Update the TMD to match. + self.tmd.content_records = self.content.content_records diff --git a/src/libWiiPy/tmd.py b/src/libWiiPy/tmd.py index 0f6bbcb..5029309 100644 --- a/src/libWiiPy/tmd.py +++ b/src/libWiiPy/tmd.py @@ -134,6 +134,7 @@ class TMD: tmd_data.seek(0x1E0) self.boot_index = int.from_bytes(tmd_data.read(2)) # Get content records for the number of contents in num_contents. + self.content_records = [] for content in range(0, self.num_contents): tmd_data.seek(0x1E4 + (36 * content)) content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36)) @@ -214,6 +215,8 @@ class TMD: # Set the TMD attribute of the object to the new raw TMD. tmd_data.seek(0x0) self.tmd = tmd_data.read() + # Clear existing lists. + self.content_records = [] # Reload object's attributes to ensure the raw data and object match. self.load() return self.tmd From 142a121fa997383e7f8c2db71240ce8e3c87f0bb Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Sat, 30 Mar 2024 02:13:34 -0400 Subject: [PATCH 14/19] Added methods to wad.py to allow changing a WAD's data Also added methods to title.py to allow dumping a full WAD. Some attributes were also removed from wad.py, because the offsets would become poisoned if any changes were made to the content sizes, and they therefore should not be read. --- src/libWiiPy/title.py | 50 ++++++++- src/libWiiPy/wad.py | 233 +++++++++++++++++++++--------------------- 2 files changed, 166 insertions(+), 117 deletions(-) diff --git a/src/libWiiPy/title.py b/src/libWiiPy/title.py index 69288d8..28ee20e 100644 --- a/src/libWiiPy/title.py +++ b/src/libWiiPy/title.py @@ -45,7 +45,7 @@ class Title: "invalid.") def dump(self) -> bytes: - """Dumps all title components (TMD, ticket, and content) back into the WAD object, and then dumps the WAD back + """Dumps all title components (TMD, Ticket, and contents) back into the WAD object, and then dumps the WAD back into raw data and returns it. Returns @@ -53,7 +53,15 @@ class Title: wad_data : bytes The raw data of the WAD. """ - # Dump the TMD. + # Dump the TMD and set it in the WAD. + self.wad.set_tmd_data(self.tmd.dump()) + # Dump the Ticket and set it in the WAD. + self.wad.set_ticket_data(self.ticket.dump()) + # Dump the ContentRegion and set it in the WAD. + self.wad.set_content_data(self.content.dump()) + # Dump the WAD with the new regions back into raw data and return it. + wad_data = self.wad.dump() + return wad_data def set_title_id(self, title_id: str) -> None: """Sets the Title ID of the title in both the TMD and Ticket. @@ -68,6 +76,44 @@ class Title: self.tmd.set_title_id(title_id) self.ticket.set_title_id(title_id) + def get_content_by_index(self, index: id) -> bytes: + """Gets an individual content from the content region based on the provided index, in decrypted form. + + Parameters + ---------- + index : int + The index of the content you want to get. + + Returns + ------- + bytes + The decrypted content listed in the content record. + """ + # Load the Title Key from the Ticket. + title_key = self.ticket.get_title_key() + # Get the decrypted content and return it. + dec_content = self.content.get_content_by_index(index, title_key) + return dec_content + + def get_content_by_cid(self, cid: int) -> bytes: + """Gets an individual content from the content region based on the provided Content ID, in decrypted form. + + Parameters + ---------- + cid : int + The Content ID of the content you want to get. Expected to be in decimal form. + + Returns + ------- + bytes + The decrypted content listed in the content record. + """ + # Load the Title Key from the Ticket. + title_key = self.ticket.get_title_key() + # Get the decrypted content and return it. + dec_content = self.content.get_content_by_cid(cid, title_key) + return dec_content + def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int, content_hash: bytes) -> None: """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index b72f52f..c605f27 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -30,13 +30,13 @@ class WAD: # This is the size of the content region, which contains all app files combined. self.wad_content_size: int = 0 self.wad_meta_size: int = 0 - # === Offsets === - self.wad_cert_offset: int = 0 - self.wad_crl_offset: int = 0 - self.wad_tik_offset: int = 0 - self.wad_tmd_offset: int = 0 - self.wad_content_offset: int = 0 - self.wad_meta_offset: int = 0 + # === Data === + self.wad_cert_data: bytes = b'' + self.wad_crl_data: bytes = b'' + self.wad_tik_data: bytes = b'' + self.wad_tmd_data: bytes = b'' + self.wad_content_data: bytes = b'' + self.wad_meta_data: bytes = b'' # Call load() to set all of the attributes from the raw WAD data provided. self.load() @@ -89,14 +89,35 @@ class WAD: # ==================================================================================== # Calculate file offsets from sizes. Every section of the WAD is padded out to a multiple of 0x40. # ==================================================================================== - self.wad_cert_offset = self.wad_hdr_size + wad_cert_offset = self.wad_hdr_size # crl isn't ever used, however an entry for its size exists in the header, so its calculated just in case. - self.wad_crl_offset = align_value(self.wad_cert_offset + self.wad_cert_size) - self.wad_tik_offset = align_value(self.wad_crl_offset + self.wad_crl_size) - self.wad_tmd_offset = align_value(self.wad_tik_offset + self.wad_tik_size) + wad_crl_offset = align_value(wad_cert_offset + self.wad_cert_size) + wad_tik_offset = align_value(wad_crl_offset + self.wad_crl_size) + wad_tmd_offset = align_value(wad_tik_offset + self.wad_tik_size) # meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things. - self.wad_meta_offset = align_value(self.wad_tmd_offset + self.wad_tmd_size) - self.wad_content_offset = align_value(self.wad_meta_offset + self.wad_meta_size) + wad_meta_offset = align_value(wad_tmd_offset + self.wad_tmd_size) + wad_content_offset = align_value(wad_meta_offset + self.wad_meta_size) + # ==================================================================================== + # Load data for each WAD section based on the previously calculated offsets. + # ==================================================================================== + # Cert data. + wad_data.seek(wad_cert_offset) + self.wad_cert_data = wad_data.read(self.wad_cert_size) + # Crl data. + wad_data.seek(wad_crl_offset) + self.wad_crl_data = wad_data.read(self.wad_crl_size) + # Ticket data. + wad_data.seek(wad_tik_offset) + self.wad_tik_data = wad_data.read(self.wad_tik_size) + # TMD data. + wad_data.seek(wad_tmd_offset) + self.wad_tmd_data = wad_data.read(self.wad_tmd_size) + # Content data. + wad_data.seek(wad_content_offset) + self.wad_content_data = wad_data.read(self.wad_content_size) + # Meta data. + wad_data.seek(wad_meta_offset) + self.wad_meta_data = wad_data.read(self.wad_meta_size) def dump(self) -> bytes: """Dumps the WAD object back into bytes. This also sets the raw WAD attribute of WAD object to the dumped data, @@ -153,78 +174,6 @@ class WAD: self.load() return self.wad - def get_cert_region(self): - """Gets the offset and size of the certificate data. - - Returns - ------- - int - The offset of the certificate data in the WAD. - int - The size of the certificate data in the WAD. - """ - return self.wad_cert_offset, self.wad_cert_size - - def get_crl_region(self): - """Gets the offset and size of the crl data. - - Returns - ------- - int - The offset of the crl data in the WAD. - int - The size of the crl data in the WAD. - """ - return self.wad_crl_offset, self.wad_crl_size - - def get_ticket_region(self): - """Gets the offset and size of the ticket data. - - Returns - ------- - int - The offset of the ticket data in the WAD. - int - The size of the ticket data in the WAD. - """ - return self.wad_tik_offset, self.wad_tik_size - - def get_tmd_region(self): - """Gets the offset and size of the TMD data. - - Returns - ------- - int - The offset of the TMD data in the WAD. - int - The size of the TMD data in the WAD. - """ - return self.wad_tmd_offset, self.wad_tmd_size - - def get_content_region(self): - """Gets the offset and size of the content of the WAD. - - Returns - ------- - int - The offset of the content data in the WAD. - int - The size of the content data in the WAD. - """ - return self.wad_content_offset, self.wad_content_size - - def get_meta_region(self): - """Gets the offset and size of the meta region of the WAD, which is typically unused. - - Returns - ------- - int - The offset of the meta region in the WAD. - int - The size of the meta region in the WAD. - """ - return self.wad_meta_offset, self.wad_meta_size - def get_wad_type(self): """Gets the type of the WAD. @@ -235,7 +184,7 @@ class WAD: """ return self.wad_type - def get_cert_data(self): + def get_cert_data(self) -> bytes: """Gets the certificate data from the WAD. Returns @@ -243,12 +192,9 @@ class WAD: bytes The certificate data. """ - wad_data = io.BytesIO(self.wad) - wad_data.seek(self.wad_cert_offset) - cert_data = wad_data.read(self.wad_cert_size) - return cert_data + return self.wad_cert_data - def get_crl_data(self): + def get_crl_data(self) -> bytes: """Gets the crl data from the WAD, if it exists. Returns @@ -256,12 +202,9 @@ class WAD: bytes The crl data. """ - wad_data = io.BytesIO(self.wad) - wad_data.seek(self.wad_crl_offset) - crl_data = wad_data.read(self.wad_crl_size) - return crl_data + return self.wad_crl_data - def get_ticket_data(self): + def get_ticket_data(self) -> bytes: """Gets the ticket data from the WAD. Returns @@ -269,12 +212,9 @@ class WAD: bytes The ticket data. """ - wad_data = io.BytesIO(self.wad) - wad_data.seek(self.wad_tik_offset) - ticket_data = wad_data.read(self.wad_tik_size) - return ticket_data + return self.wad_tik_data - def get_tmd_data(self): + def get_tmd_data(self) -> bytes: """Returns the TMD data from the WAD. Returns @@ -282,12 +222,9 @@ class WAD: bytes The TMD data. """ - wad_data = io.BytesIO(self.wad) - wad_data.seek(self.wad_tmd_offset) - tmd_data = wad_data.read(self.wad_tmd_size) - return tmd_data + return self.wad_tmd_data - def get_content_data(self): + def get_content_data(self) -> bytes: """Gets the content of the WAD. Returns @@ -295,12 +232,9 @@ class WAD: bytes The content data. """ - wad_data = io.BytesIO(self.wad) - wad_data.seek(self.wad_content_offset) - content_data = wad_data.read(self.wad_content_size) - return content_data + return self.wad_content_data - def get_meta_data(self): + def get_meta_data(self) -> bytes: """Gets the meta region of the WAD, which is typically unused. Returns @@ -308,7 +242,76 @@ class WAD: bytes The meta region. """ - wad_data = io.BytesIO(self.wad) - wad_data.seek(self.wad_meta_offset) - meta_data = wad_data.read(self.wad_meta_size) - return meta_data + return self.wad_meta_data + + def set_cert_data(self, cert_data) -> None: + """Sets the certificate data of the WAD. Also calculates the new size. + + Parameters + ---------- + cert_data : bytes + The new certificate data. + """ + self.wad_cert_data = cert_data + # Calculate the size of the new cert data. + self.wad_cert_size = len(cert_data) + + def set_crl_data(self, crl_data) -> None: + """Sets the crl data of the WAD. Also calculates the new size. + + Parameters + ---------- + crl_data : bytes + The new crl data. + """ + self.wad_crl_data = crl_data + # Calculate the size of the new crl data. + self.wad_crl_size = len(crl_data) + + def set_tmd_data(self, tmd_data) -> None: + """Sets the TMD data of the WAD. Also calculates the new size. + + Parameters + ---------- + tmd_data : bytes + The new TMD data. + """ + self.wad_tmd_data = tmd_data + # Calculate the size of the new TMD data. + self.wad_tmd_size = len(tmd_data) + + def set_ticket_data(self, tik_data) -> None: + """Sets the Ticket data of the WAD. Also calculates the new size. + + Parameters + ---------- + tik_data : bytes + The new TMD data. + """ + self.wad_tik_data = tik_data + # Calculate the size of the new Ticket data. + self.wad_tik_size = len(tik_data) + + def set_content_data(self, content_data) -> None: + """Sets the content data of the WAD. Also calculates the new size. + + Parameters + ---------- + content_data : bytes + The new content data. + """ + self.wad_content_data = content_data + # Calculate the size of the new content data. + self.wad_content_size = len(content_data) + + def set_meta_data(self, meta_data) -> None: + """Sets the meta data of the WAD. Also calculates the new size. + + Parameters + ---------- + meta_data : bytes + The new meta data. + """ + self.wad_meta_data = meta_data + # Calculate the size of the new meta data. + self.wad_meta_size = len(meta_data) From 640ca91716e3e5704e6b4e9c74e021682209affa Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Sun, 31 Mar 2024 23:38:52 -0400 Subject: [PATCH 15/19] Allow creating blank objects for WADs, TMDs, Tickets, ContentRegions, and Titles to make WAD packing possible --- src/libWiiPy/content.py | 46 ++++++++++++++++++++--------------------- src/libWiiPy/ticket.py | 35 ++++++++++++++----------------- src/libWiiPy/title.py | 38 +++++++++++++++++++++------------- src/libWiiPy/tmd.py | 35 ++++++++++++------------------- src/libWiiPy/wad.py | 37 ++++++++++++++------------------- 5 files changed, 89 insertions(+), 102 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index 64d2340..344f1a3 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -13,33 +13,27 @@ from .crypto import decrypt_content, encrypt_content class ContentRegion: """Creates a ContentRegion object to parse the continuous content region of a WAD. - - Parameters - ---------- - 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 + def __init__(self): + self.content_records: List[ContentRecord] = [] self.content_region_size: int = 0 # Size of the content region. self.num_contents: int = 0 # Number of contents in the content region. self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region. self.content_list: List[bytes] = [] - # Call load() to set all of the attributes from the raw content region provided. - self.load() - def load(self): + def load(self, content_region: bytes, content_records: List[ContentRecord]) -> None: """Loads the raw content region and builds a list of all the contents. - Returns - ------- - none + Parameters + ---------- + content_region : bytes + The raw data for the content region being loaded. + content_records : list[ContentRecord] + A list of ContentRecord objects detailing all contents contained in the region. """ - with io.BytesIO(self.content_region) as content_region_data: + self.content_records = content_records + 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) @@ -86,13 +80,9 @@ class ContentRegion: if padding_bytes > 0: content_region_data.write(b'\x00' * padding_bytes) content_region_data.seek(0x0) - self.content_region = content_region_data.read() - # Clear existing lists. - self.content_start_offsets = [0] - self.content_list = [] - # Reload object's attributes to ensure the raw data and object match. - self.load() - return self.content_region + content_region_raw = content_region_data.read() + # Return the raw ContentRegion for the data contained in the object. + return content_region_raw def get_enc_content_by_index(self, index: int) -> bytes: """Gets an individual content from the content region based on the provided index, in encrypted form. @@ -291,3 +281,11 @@ class ContentRegion: enc_content = encrypt_content(dec_content, title_key, index) # Pass values to set_enc_content() self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash) + + def load_enc_content(self, enc_content: bytes, index: int) -> bytes: + """Loads the provided encrypted content into the content region at the specified index, with the assumption that + it matches the record at that index. + + :param index: + :return: + """ diff --git a/src/libWiiPy/ticket.py b/src/libWiiPy/ticket.py index c61a2a5..39cd53c 100644 --- a/src/libWiiPy/ticket.py +++ b/src/libWiiPy/ticket.py @@ -11,12 +11,9 @@ from typing import List class Ticket: - """Creates a Ticket object to parse a Ticket file to retrieve the Title Key needed to decrypt it. - - Parameters - ---------- - ticket : bytes - A bytes object containing the contents of a ticket file. + """ + Creates a Ticket object that allows for either loading and editing an existing Ticket or creating one manually if + desired. Attributes ---------- @@ -35,8 +32,7 @@ class Ticket: common_key_index : int The index of the common key required to decrypt this ticket's Title Key. """ - def __init__(self, ticket): - self.ticket = ticket + def __init__(self): # Signature blob header self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048 self.signature: bytes = b'' # Actual signature data @@ -60,17 +56,17 @@ class Ticket: self.title_limits_list: List[TitleLimit] = [] # List of play limits applied to the title. # v1 ticket data # TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1. - # Call load() to set all of the attributes from the raw Ticket data provided. - self.load() - def load(self): - """Loads the raw Ticket data and sets all attributes of the Ticket object. + def load(self, ticket: bytes) -> None: + """Loads raw Ticket data and sets all attributes of the WAD object. This allows for manipulating an already + existing Ticket. - Returns - ------- - none + Parameters + ---------- + ticket : bytes + The data for the Ticket you wish to load. """ - with io.BytesIO(self.ticket) as ticket_data: + with io.BytesIO(ticket) as ticket_data: # ==================================================================================== # Parses each of the keys contained in the Ticket. # ==================================================================================== @@ -209,10 +205,9 @@ class Ticket: title_limit_data.close() # Set the Ticket attribute of the object to the new raw Ticket. ticket_data.seek(0x0) - self.ticket = ticket_data.read() - # Reload object's attributes to ensure the raw data and object match. - self.load() - return self.ticket + ticket_data_raw = ticket_data.read() + # Return the raw TMD for the data contained in the object. + return ticket_data_raw def get_title_id(self): """Gets the Title ID of the ticket's associated title. diff --git a/src/libWiiPy/title.py b/src/libWiiPy/title.py index 28ee20e..05e0dc5 100644 --- a/src/libWiiPy/title.py +++ b/src/libWiiPy/title.py @@ -12,11 +12,6 @@ from .wad import WAD class Title: """Creates a Title object that contains all components of a title, and allows altering them. - Parameters - ---------- - wad : WAD - A WAD object to load data from. - Attributes ---------- tmd : TMD @@ -26,18 +21,33 @@ class Title: content: ContentRegion A ContentRegion object containing the title's contents. """ - def __init__(self, wad: WAD): - self.wad = wad - self.tmd: TMD - self.ticket: Ticket - self.content: ContentRegion - # Load data from the WAD object, and generate all other objects from the data in it. + def __init__(self): + self.wad: WAD = WAD() + self.tmd: TMD = TMD() + self.ticket: Ticket = Ticket() + self.content: ContentRegion = ContentRegion() + + def set_wad(self, wad: bytes) -> None: + """Load existing WAD data into the title and create WAD, TMD, Ticket, and ContentRegion objects based off of it + to allow you to modify that data. Note that this will overwrite any existing data for this title. + + Parameters + ---------- + wad : bytes + The data for the WAD you wish to load. + """ + # Create a new WAD object based on the WAD data provided. + self.wad = WAD() + self.wad.load(wad) # Load the TMD. - self.tmd = TMD(self.wad.get_tmd_data()) + self.tmd = TMD() + self.tmd.load(self.wad.get_tmd_data()) # Load the ticket. - self.ticket = Ticket(self.wad.get_ticket_data()) + self.ticket = Ticket() + self.ticket.load(self.wad.get_ticket_data()) # Load the content. - self.content = ContentRegion(self.wad.get_content_data(), self.tmd.content_records) + self.content = ContentRegion() + self.content.load(self.wad.get_content_data(), self.tmd.content_records) # Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an # error because clearly something strange has gone on with the WAD and editing it probably won't work. if self.tmd.title_id != self.ticket.title_id_str: diff --git a/src/libWiiPy/tmd.py b/src/libWiiPy/tmd.py index 5029309..7ead8c0 100644 --- a/src/libWiiPy/tmd.py +++ b/src/libWiiPy/tmd.py @@ -12,12 +12,7 @@ from .types import ContentRecord class TMD: """ - Creates a TMD object to parse a TMD file to retrieve information about a title. - - Parameters - ---------- - tmd : bytes - A bytes object containing the contents of a TMD file. + Creates a TMD object that allows for either loading and editing an existing TMD or creating one manually if desired. Attributes ---------- @@ -34,8 +29,7 @@ class TMD: num_contents : int The number of contents listed in the TMD. """ - def __init__(self, tmd): - self.tmd = tmd + def __init__(self): self.blob_header: bytes = b'' self.sig_type: int = 0 self.sig: bytes = b'' @@ -57,17 +51,17 @@ class TMD: self.num_contents: int = 0 # The number of contents contained in the associated title. self.boot_index: int = 0 self.content_records: List[ContentRecord] = [] - # Call load() to set all of the attributes from the raw TMD data provided. - self.load() - def load(self): - """Loads the raw TMD data and sets all attributes of the TMD object. + def load(self, tmd: bytes) -> None: + """Loads raw TMD data and sets all attributes of the WAD object. This allows for manipulating an already + existing TMD. - Returns - ------- - none + Parameters + ---------- + tmd : bytes + The data for the TMD you wish to load. """ - with io.BytesIO(self.tmd) as tmd_data: + with io.BytesIO(tmd) as tmd_data: # ==================================================================================== # Parses each of the keys contained in the TMD. # ==================================================================================== @@ -214,12 +208,9 @@ class TMD: content_data.close() # Set the TMD attribute of the object to the new raw TMD. tmd_data.seek(0x0) - self.tmd = tmd_data.read() - # Clear existing lists. - self.content_records = [] - # Reload object's attributes to ensure the raw data and object match. - self.load() - return self.tmd + tmd_data_raw = tmd_data.read() + # Return the raw TMD for the data contained in the object. + return tmd_data_raw def get_title_region(self): """Gets the region of the TMD's associated title. diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index c605f27..5e3de1f 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -10,15 +10,9 @@ from .shared import align_value, pad_bytes_stream class WAD: """ - Creates a WAD object to parse the header of a WAD file and retrieve the data contained in it. - - Parameters - ---------- - wad : bytes - A bytes object containing the contents of a WAD file. + Creates a WAD object that allows for either loading and editing an existing WAD or creating a new WAD from raw data. """ - def __init__(self, wad): - self.wad = wad + def __init__(self): self.wad_hdr_size: int = 64 self.wad_type: str = "" self.wad_version: bytes = b'' @@ -37,17 +31,17 @@ class WAD: self.wad_tmd_data: bytes = b'' self.wad_content_data: bytes = b'' self.wad_meta_data: bytes = b'' - # Call load() to set all of the attributes from the raw WAD data provided. - self.load() - def load(self): - """Loads the raw WAD data and sets all attributes of the WAD object. + def load(self, wad_data) -> None: + """Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already + existing WAD file. - Returns - ------- - none + Parameters + ---------- + wad_data : bytes + The data for the WAD you wish to load. """ - with io.BytesIO(self.wad) as wad_data: + with io.BytesIO(wad_data) as wad_data: # Read the first 8 bytes of the file to ensure that it's a WAD. This will currently reject boot2 WADs, but # this tool cannot handle them correctly right now anyway. wad_data.seek(0x0) @@ -120,8 +114,8 @@ class WAD: self.wad_meta_data = wad_data.read(self.wad_meta_size) def dump(self) -> bytes: - """Dumps the WAD object back into bytes. This also sets the raw WAD attribute of WAD object to the dumped data, - and triggers load() again to ensure that the raw data and object match. + """Dumps the WAD object into the raw WAD file. This allows for creating a WAD file from the data contained in + the WAD object. Returns ------- @@ -169,10 +163,9 @@ class WAD: wad_data = pad_bytes_stream(wad_data) # Seek to the beginning and save this as the WAD data for the object. wad_data.seek(0x0) - self.wad = wad_data.read() - # Reload object's attributes to ensure the raw data and object match. - self.load() - return self.wad + wad_data_raw = wad_data.read() + # Return the raw WAD file for the data contained in the object. + return wad_data_raw def get_wad_type(self): """Gets the type of the WAD. From 57fb0576e933738e25fe49a4a2b60b377e53e323 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Mon, 1 Apr 2024 22:16:42 -0400 Subject: [PATCH 16/19] WADs can now be packed properly via title.py or low-level methods --- src/libWiiPy/content.py | 56 +++++++++++++++++++++++++++++++++++------ src/libWiiPy/title.py | 50 ++++++++++++++++++++++++++++++++++-- src/libWiiPy/wad.py | 4 +-- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index 344f1a3..97851f5 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -157,14 +157,14 @@ class ContentRegion: 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_dec_hash = hashlib.sha1(content_dec).hexdigest() 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: + if content_dec_hash != content_record_hash: 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())) + "Actual hash is: {}".format(content_dec_hash)) return content_dec def get_content_by_cid(self, cid: int, title_key: bytes) -> bytes: @@ -282,10 +282,52 @@ class ContentRegion: # Pass values to set_enc_content() self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash) - def load_enc_content(self, enc_content: bytes, index: int) -> bytes: + def load_enc_content(self, enc_content: bytes, index: int) -> None: """Loads the provided encrypted content into the content region at the specified index, with the assumption that - it matches the record at that index. + it matches the record at that index. Not recommended for most use cases, use decrypted content and + load_content() instead. - :param index: - :return: + Parameters + ---------- + enc_content : bytes + The encrypted content to load. + index : int + The content index to load the content at. """ + if (index + 1) > len(self.content_records) or len(self.content_records) == 0: + raise IndexError("No content records have been loaded, or that index is higher than the highest entry in " + "the content records.") + if (index + 1) > len(self.content_list): + self.content_list.append(enc_content) + else: + self.content_list[index] = enc_content + + def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None: + """Loads the provided decrypted content into the content region at the specified index, but first checks to make + sure it matches the record at that index before loading. This content will be encrypted when loaded. + + Parameters + ---------- + dec_content : bytes + The decrypted content to load. + index : int + The content index to load the content at. + title_key: bytes + The Title Key that matches the decrypted content. + """ + # Make sure that content records exist and that the provided index exists in them. + if (index + 1) > len(self.content_records) or len(self.content_records) == 0: + raise IndexError("No content records have been loaded, or that index is higher than the highest entry in " + "the content records.") + # Check the hash of the content against the hash stored in the record to ensure it matches. + content_hash = hashlib.sha1(dec_content).hexdigest() + if content_hash != self.content_records[index].content_hash.decode(): + raise ValueError("The decrypted content provided does not match the record at the provided index. \n" + "Expected hash is: {}\n".format(self.content_records[index].content_hash.decode()) + + "Actual hash is: {}".format(content_hash)) + # If the hash matches, encrypt the content and set it where it belongs. + enc_content = encrypt_content(dec_content, title_key, index) + if (index + 1) > len(self.content_list): + self.content_list.append(enc_content) + else: + self.content_list[index] = enc_content diff --git a/src/libWiiPy/title.py b/src/libWiiPy/title.py index 05e0dc5..69e9a52 100644 --- a/src/libWiiPy/title.py +++ b/src/libWiiPy/title.py @@ -27,7 +27,7 @@ class Title: self.ticket: Ticket = Ticket() self.content: ContentRegion = ContentRegion() - def set_wad(self, wad: bytes) -> None: + def load_wad(self, wad: bytes) -> None: """Load existing WAD data into the title and create WAD, TMD, Ticket, and ContentRegion objects based off of it to allow you to modify that data. Note that this will overwrite any existing data for this title. @@ -54,7 +54,7 @@ class Title: raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be " "invalid.") - def dump(self) -> bytes: + def dump_wad(self) -> bytes: """Dumps all title components (TMD, Ticket, and contents) back into the WAD object, and then dumps the WAD back into raw data and returns it. @@ -73,6 +73,38 @@ class Title: wad_data = self.wad.dump() return wad_data + def load_tmd(self, tmd: bytes) -> None: + """Load existing TMD data into the title. Note that this will overwrite any existing TMD data for this title. + + Parameters + ---------- + tmd : bytes + The data for the WAD you wish to load. + """ + # Load TMD. + self.tmd.load(tmd) + + def load_ticket(self, ticket: bytes) -> None: + """Load existing Ticket data into the title. Note that this will overwrite any existing Ticket data for this + title. + + Parameters + ---------- + ticket : bytes + The data for the WAD you wish to load. + """ + # Load Ticket. + self.ticket.load(ticket) + + def load_content_records(self) -> None: + """Load content records from the TMD into the ContentRegion to allow loading content files based on the records. + This requires that a TMD has already been loaded and will throw an exception if it isn't. + """ + if not self.tmd.content_records: + ValueError("No TMD appears to have been loaded, so content records cannot be read from it.") + # Load the content records into the ContentRegion object. + self.content.content_records = self.tmd.content_records + def set_title_id(self, title_id: str) -> None: """Sets the Title ID of the title in both the TMD and Ticket. @@ -170,3 +202,17 @@ class Title: self.content.set_content(dec_content, cid, index, content_type, self.ticket.get_title_key()) # Update the TMD to match. self.tmd.content_records = self.content.content_records + + def load_content(self, dec_content: bytes, index: int) -> None: + """Loads the provided decrypted content into the content region at the specified index, but first checks to make + sure it matches the record at that index before loading. This content will be encrypted when loaded. + + Parameters + ---------- + dec_content : bytes + The decrypted content to load. + index : int + The content index to load the content at. + """ + # Load the decrypted content. + self.content.load_content(dec_content, index, self.ticket.get_title_key()) diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index 5e3de1f..f98c323 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -14,8 +14,8 @@ class WAD: """ def __init__(self): self.wad_hdr_size: int = 64 - self.wad_type: str = "" - self.wad_version: bytes = b'' + self.wad_type: str = "Is" + self.wad_version: bytes = b'\x00\x00' # === Sizes === self.wad_cert_size: int = 0 self.wad_crl_size: int = 0 From b9edeb160dda37c9a65970308052ba2e3a81e0f5 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Tue, 2 Apr 2024 18:14:24 -0400 Subject: [PATCH 17/19] Remove legacy fix in crypto.py that is no longer needed and was causing issues with encryption --- src/libWiiPy/crypto.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libWiiPy/crypto.py b/src/libWiiPy/crypto.py index 9b0f61a..4314508 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -62,9 +62,9 @@ def decrypt_content(content_enc, title_key, content_index, content_length) -> by content_index_bin = struct.pack('>H', content_index) while len(content_index_bin) < 16: content_index_bin += b'\x00' - # 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: - content_enc = content_enc + (b'\x00' * (64 - (len(content_enc) % 64))) + # Align content to 16 bytes to ensure that it works with AES encryption. + if (len(content_enc) % 16) != 0: + content_enc = content_enc + (b'\x00' * (16 - (len(content_enc) % 16))) # 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. @@ -101,9 +101,9 @@ def encrypt_content(content_dec, title_key, content_index) -> bytes: content_index_bin += b'\x00' # Calculate the intended size of the encrypted content. enc_size = len(content_dec) + (16 - (len(content_dec) % 16)) - # Align content to 64 bytes to ensure that all the data is being encrypted, and so it works with AES encryption. - if (len(content_dec) % 64) != 0: - content_dec = content_dec + (b'\x00' * (64 - (len(content_dec) % 64))) + # Align content to 16 bytes to ensure that it works with AES encryption. + if (len(content_dec) % 16) != 0: + content_dec = content_dec + (b'\x00' * (16 - (len(content_dec) % 16))) # 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) # Encrypt the content using the AES object. From 7c631454a1c1caff44b83630e0929e223ad64b60 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:46:51 -0400 Subject: [PATCH 18/19] Small README update to explain current features better --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 35fe7ee..ba8f88a 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSh # Features This list will expand as libWiiPy is developed, but these features are currently available: - TMD, ticket, and WAD parsing -- Title Key and content decryption and extraction -- Content encryption and WAD packing +- WAD content extraction, decryption, re-encryption, and packing # Usage A wiki, and in the future a potential documenation site, is being worked on, and can be accessed [here](https://github.com/NinjaCheetah/libWiiPy/wiki). It is currently fairly barebones, but it will be improved in the future. From e85eae567a6a83a61d65019b7640b9308cc09796 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Wed, 3 Apr 2024 18:05:12 -0400 Subject: [PATCH 19/19] Improved docstrings and made them all much more consistent across the project --- src/libWiiPy/__init__.py | 2 ++ src/libWiiPy/commonkeys.py | 5 +-- src/libWiiPy/content.py | 49 ++++++++++++++++++++-------- src/libWiiPy/crypto.py | 9 ++++-- src/libWiiPy/shared.py | 13 +++++--- src/libWiiPy/ticket.py | 29 ++++++++++------- src/libWiiPy/title.py | 40 ++++++++++++++++------- src/libWiiPy/tmd.py | 36 +++++++++++++-------- src/libWiiPy/types.py | 12 ++++--- src/libWiiPy/wad.py | 66 ++++++++++++++++++++++++++++---------- 10 files changed, 179 insertions(+), 82 deletions(-) diff --git a/src/libWiiPy/__init__.py b/src/libWiiPy/__init__.py index 7530d43..b607d16 100644 --- a/src/libWiiPy/__init__.py +++ b/src/libWiiPy/__init__.py @@ -1,5 +1,7 @@ # "__init__.py" from libWiiPy by NinjaCheetah & Contributors # https://github.com/NinjaCheetah/libWiiPy +# +# These are the essential modules from libWiiPy that you'd probably want imported by default. from .commonkeys import * from .content import * diff --git a/src/libWiiPy/commonkeys.py b/src/libWiiPy/commonkeys.py index b1fd7d2..04755e2 100644 --- a/src/libWiiPy/commonkeys.py +++ b/src/libWiiPy/commonkeys.py @@ -8,8 +8,9 @@ korean_key = '63b82bb4f4614e2e13f2fefbba4c9b7e' vwii_key = '30bfc76e7c19afbb23163330ced7c28d' -def get_common_key(common_key_index): - """Gets the specified Wii Common Key based on the index provided. +def get_common_key(common_key_index) -> bytes: + """ + Gets the specified Wii Common Key based on the index provided. Possible values for common_key_index: 0: Common Key, 1: Korean Key, 2: vWii Key diff --git a/src/libWiiPy/content.py b/src/libWiiPy/content.py index 97851f5..ddb6476 100644 --- a/src/libWiiPy/content.py +++ b/src/libWiiPy/content.py @@ -12,7 +12,16 @@ from .crypto import decrypt_content, encrypt_content class ContentRegion: - """Creates a ContentRegion object to parse the continuous content region of a WAD. + """ + A ContentRegion object to parse the continuous content region of a WAD. Allows for retrieving content from the + region in both encrypted or decrypted form, and setting new content. + + Attributes + ---------- + content_records : List[ContentRecord] + The content records for the content stored in the region. + num_contents : int + The total number of contents stored in the region. """ def __init__(self): @@ -23,7 +32,8 @@ class ContentRegion: self.content_list: List[bytes] = [] def load(self, content_region: bytes, content_records: List[ContentRecord]) -> None: - """Loads the raw content region and builds a list of all the contents. + """ + Loads the raw content region and builds a list of all the contents. Parameters ---------- @@ -59,7 +69,8 @@ class ContentRegion: self.content_list.append(content_enc) def dump(self) -> bytes: - """Takes the list of contents and assembles them back into one content region. Returns this content region as a + """ + Takes the list of contents and assembles them back into one content region. Returns this content region as a bytes object and sets the raw content region variable to this result, then calls load() again to make sure the content list matches the raw data. @@ -85,7 +96,8 @@ class ContentRegion: return content_region_raw def get_enc_content_by_index(self, index: int) -> bytes: - """Gets an individual content from the content region based on the provided index, in encrypted form. + """ + Gets an individual content from the content region based on the provided index, in encrypted form. Parameters ---------- @@ -101,7 +113,8 @@ class ContentRegion: return content_enc def get_enc_content_by_cid(self, cid: int) -> bytes: - """Gets an individual content from the content region based on the provided Content ID, in encrypted form. + """ + Gets an individual content from the content region based on the provided Content ID, in encrypted form. Parameters ---------- @@ -127,7 +140,8 @@ class ContentRegion: return content_enc def get_enc_contents(self) -> List[bytes]: - """Gets a list of all encrypted contents from the content region. + """ + Gets a list of all encrypted contents from the content region. Returns ------- @@ -137,7 +151,8 @@ class ContentRegion: return self.content_list def get_content_by_index(self, index: int, title_key: bytes) -> bytes: - """Gets an individual content from the content region based on the provided index, in decrypted form. + """ + Gets an individual content from the content region based on the provided index, in decrypted form. Parameters ---------- @@ -168,7 +183,8 @@ class ContentRegion: return content_dec def get_content_by_cid(self, cid: int, title_key: bytes) -> bytes: - """Gets an individual content from the content region based on the provided Content ID, in decrypted form. + """ + Gets an individual content from the content region based on the provided Content ID, in decrypted form. Parameters ---------- @@ -196,7 +212,8 @@ class ContentRegion: return content_dec def get_contents(self, title_key: bytes) -> List[bytes]: - """Gets a list of all contents from the content region, in decrypted form. + """ + Gets a list of all contents from the content region, in decrypted form. Parameters ---------- @@ -216,7 +233,8 @@ class ContentRegion: def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int, content_hash: bytes) -> None: - """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + """ + Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are set in the content record, with a new record being added if necessary. Parameters @@ -257,8 +275,9 @@ class ContentRegion: self.content_list[index] = enc_content def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None: - """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are - set in the content record, with a new record being added if necessary. + """ + Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + set in the content record, with a new record being added if necessary. Parameters ---------- @@ -283,7 +302,8 @@ class ContentRegion: self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash) def load_enc_content(self, enc_content: bytes, index: int) -> None: - """Loads the provided encrypted content into the content region at the specified index, with the assumption that + """ + Loads the provided encrypted content into the content region at the specified index, with the assumption that it matches the record at that index. Not recommended for most use cases, use decrypted content and load_content() instead. @@ -303,7 +323,8 @@ class ContentRegion: self.content_list[index] = enc_content def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None: - """Loads the provided decrypted content into the content region at the specified index, but first checks to make + """ + Loads the provided decrypted content into the content region at the specified index, but first checks to make sure it matches the record at that index before loading. This content will be encrypted when loaded. Parameters diff --git a/src/libWiiPy/crypto.py b/src/libWiiPy/crypto.py index 4314508..1a6b57a 100644 --- a/src/libWiiPy/crypto.py +++ b/src/libWiiPy/crypto.py @@ -7,7 +7,8 @@ from Crypto.Cipher import AES def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes: - """Gets the decrypted version of the encrypted Title Key provided. + """ + 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. @@ -37,7 +38,8 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes: def decrypt_content(content_enc, title_key, content_index, content_length) -> bytes: - """Gets the decrypted version of the encrypted content. + """ + Gets the decrypted version of the encrypted content. This requires the index of the content to decrypt as it is used as the IV, as well as the content length to adjust padding as necessary. @@ -76,7 +78,8 @@ def decrypt_content(content_enc, title_key, content_index, content_length) -> by def encrypt_content(content_dec, title_key, content_index) -> bytes: - """Gets the encrypted version of the decrypted content. + """ + Gets the encrypted version of the decrypted content. This requires the index of the content to encrypt as it is used as the IV, as well as the content length to adjust padding as necessary. diff --git a/src/libWiiPy/shared.py b/src/libWiiPy/shared.py index e2ed428..dd00602 100644 --- a/src/libWiiPy/shared.py +++ b/src/libWiiPy/shared.py @@ -1,8 +1,12 @@ # "shared.py" from libWiiPy by NinjaCheetah & Contributors # https://github.com/NinjaCheetah/libWiiPy +# +# This file defines general functions that may be useful in other modules of libWiiPy. Putting them here cuts down on +# clutter in other files. -def align_value(value, alignment=64): - """Aligns the provided value to the set alignment (defaults to 64). +def align_value(value, alignment=64) -> int: + """ + Aligns the provided value to the set alignment (defaults to 64). Parameters ---------- @@ -22,8 +26,9 @@ def align_value(value, alignment=64): return value -def pad_bytes_stream(data, alignment=64): - """Pads the provided bytes stream to the provided alignment (defaults to 64). +def pad_bytes_stream(data, alignment=64) -> bytes: + """ + Pads the provided bytes stream to the provided alignment (defaults to 64). Parameters ---------- diff --git a/src/libWiiPy/ticket.py b/src/libWiiPy/ticket.py index 39cd53c..2cefbdc 100644 --- a/src/libWiiPy/ticket.py +++ b/src/libWiiPy/ticket.py @@ -12,8 +12,7 @@ from typing import List class Ticket: """ - Creates a Ticket object that allows for either loading and editing an existing Ticket or creating one manually if - desired. + A Ticket object that allows for either loading and editing an existing Ticket or creating one manually if desired. Attributes ---------- @@ -58,7 +57,8 @@ class Ticket: # TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1. def load(self, ticket: bytes) -> None: - """Loads raw Ticket data and sets all attributes of the WAD object. This allows for manipulating an already + """ + Loads raw Ticket data and sets all attributes of the WAD object. This allows for manipulating an already existing Ticket. Parameters @@ -138,7 +138,8 @@ class Ticket: self.title_limits_list.append(TitleLimit(limit_type, limit_value)) def dump(self) -> bytes: - """Dumps the Ticket object back into bytes. This also sets the raw Ticket attribute of Ticket object to the + """ + Dumps the Ticket object back into bytes. This also sets the raw Ticket attribute of Ticket object to the dumped data, and triggers load() again to ensure that the raw data and object match. Returns @@ -209,8 +210,9 @@ class Ticket: # Return the raw TMD for the data contained in the object. return ticket_data_raw - def get_title_id(self): - """Gets the Title ID of the ticket's associated title. + def get_title_id(self) -> str: + """ + Gets the Title ID of the ticket's associated title. Returns ------- @@ -220,8 +222,9 @@ class Ticket: title_id_str = str(self.title_id.decode()) return title_id_str - def get_common_key_type(self): - """Gets the name of the common key used to encrypt the Title Key contained in the ticket. + def get_common_key_type(self) -> str: + """ + Gets the name of the common key used to encrypt the Title Key contained in the ticket. Returns ------- @@ -240,8 +243,9 @@ class Ticket: case 2: return "vWii" - def get_title_key(self): - """Gets the decrypted title key contained in the ticket. + def get_title_key(self) -> bytes: + """ + Gets the decrypted title key contained in the ticket. Returns ------- @@ -251,8 +255,9 @@ class Ticket: title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id) return title_key - def set_title_id(self, title_id): - """Sets the Title ID of the title in the Ticket. + def set_title_id(self, title_id) -> None: + """ + Sets the Title ID of the title in the Ticket. Parameters ---------- diff --git a/src/libWiiPy/title.py b/src/libWiiPy/title.py index 69e9a52..081bbc7 100644 --- a/src/libWiiPy/title.py +++ b/src/libWiiPy/title.py @@ -10,10 +10,15 @@ from .wad import WAD class Title: - """Creates a Title object that contains all components of a title, and allows altering them. + """ + A Title object that contains all components of a title, and allows altering them. Provides higher-level access + than manually creating WAD, TMD, Ticket, and ContentRegion objects and ensures that any data that needs to match + between files matches. Attributes ---------- + wad : WAD + A WAD object of a WAD containing the title's data. tmd : TMD A TMD object of the title's TMD. ticket : Ticket @@ -28,7 +33,8 @@ class Title: self.content: ContentRegion = ContentRegion() def load_wad(self, wad: bytes) -> None: - """Load existing WAD data into the title and create WAD, TMD, Ticket, and ContentRegion objects based off of it + """ + Load existing WAD data into the title and create WAD, TMD, Ticket, and ContentRegion objects based off of it to allow you to modify that data. Note that this will overwrite any existing data for this title. Parameters @@ -55,7 +61,8 @@ class Title: "invalid.") def dump_wad(self) -> bytes: - """Dumps all title components (TMD, Ticket, and contents) back into the WAD object, and then dumps the WAD back + """ + Dumps all title components (TMD, Ticket, and contents) back into the WAD object, and then dumps the WAD back into raw data and returns it. Returns @@ -74,7 +81,8 @@ class Title: return wad_data def load_tmd(self, tmd: bytes) -> None: - """Load existing TMD data into the title. Note that this will overwrite any existing TMD data for this title. + """ + Load existing TMD data into the title. Note that this will overwrite any existing TMD data for this title. Parameters ---------- @@ -85,7 +93,8 @@ class Title: self.tmd.load(tmd) def load_ticket(self, ticket: bytes) -> None: - """Load existing Ticket data into the title. Note that this will overwrite any existing Ticket data for this + """ + Load existing Ticket data into the title. Note that this will overwrite any existing Ticket data for this title. Parameters @@ -97,7 +106,8 @@ class Title: self.ticket.load(ticket) def load_content_records(self) -> None: - """Load content records from the TMD into the ContentRegion to allow loading content files based on the records. + """ + Load content records from the TMD into the ContentRegion to allow loading content files based on the records. This requires that a TMD has already been loaded and will throw an exception if it isn't. """ if not self.tmd.content_records: @@ -106,7 +116,8 @@ class Title: self.content.content_records = self.tmd.content_records def set_title_id(self, title_id: str) -> None: - """Sets the Title ID of the title in both the TMD and Ticket. + """ + Sets the Title ID of the title in both the TMD and Ticket. Parameters ---------- @@ -119,7 +130,8 @@ class Title: self.ticket.set_title_id(title_id) def get_content_by_index(self, index: id) -> bytes: - """Gets an individual content from the content region based on the provided index, in decrypted form. + """ + Gets an individual content from the content region based on the provided index, in decrypted form. Parameters ---------- @@ -138,7 +150,8 @@ class Title: return dec_content def get_content_by_cid(self, cid: int) -> bytes: - """Gets an individual content from the content region based on the provided Content ID, in decrypted form. + """ + Gets an individual content from the content region based on the provided Content ID, in decrypted form. Parameters ---------- @@ -158,7 +171,8 @@ class Title: def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int, content_hash: bytes) -> None: - """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + """ + Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are set in the content record, with a new record being added if necessary. The TMD is also updated to match the new records. @@ -183,7 +197,8 @@ class Title: self.tmd.content_records = self.content.content_records def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int) -> None: - """Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are + """ + Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are set in the content record, with a new record being added if necessary. The Title Key is sourced from this title's loaded ticket. The TMD is also updated to match the new records. @@ -204,7 +219,8 @@ class Title: self.tmd.content_records = self.content.content_records def load_content(self, dec_content: bytes, index: int) -> None: - """Loads the provided decrypted content into the content region at the specified index, but first checks to make + """ + Loads the provided decrypted content into the content region at the specified index, but first checks to make sure it matches the record at that index before loading. This content will be encrypted when loaded. Parameters diff --git a/src/libWiiPy/tmd.py b/src/libWiiPy/tmd.py index 7ead8c0..54cbb07 100644 --- a/src/libWiiPy/tmd.py +++ b/src/libWiiPy/tmd.py @@ -12,7 +12,7 @@ from .types import ContentRecord class TMD: """ - Creates a TMD object that allows for either loading and editing an existing TMD or creating one manually if desired. + A TMD object that allows for either loading and editing an existing TMD or creating one manually if desired. Attributes ---------- @@ -53,7 +53,8 @@ class TMD: self.content_records: List[ContentRecord] = [] def load(self, tmd: bytes) -> None: - """Loads raw TMD data and sets all attributes of the WAD object. This allows for manipulating an already + """ + Loads raw TMD data and sets all attributes of the WAD object. This allows for manipulating an already existing TMD. Parameters @@ -138,7 +139,8 @@ class TMD: binascii.hexlify(content_record_hdr[4]))) def dump(self) -> bytes: - """Dumps the TMD object back into bytes. This also sets the raw TMD attribute of TMD object to the dumped data, + """ + Dumps the TMD object back into bytes. This also sets the raw TMD attribute of TMD object to the dumped data, and triggers load() again to ensure that the raw data and object match. Returns @@ -212,8 +214,9 @@ class TMD: # Return the raw TMD for the data contained in the object. return tmd_data_raw - def get_title_region(self): - """Gets the region of the TMD's associated title. + def get_title_region(self) -> str: + """ + Gets the region of the TMD's associated title. Can be one of several possible values: 'JAP', 'USA', 'EUR', 'NONE', or 'KOR'. @@ -235,8 +238,9 @@ class TMD: case 4: return "KOR" - def get_is_vwii_title(self): - """Gets whether the TMD is designed for the vWii or not. + def get_is_vwii_title(self) -> bool: + """ + Gets whether the TMD is designed for the vWii or not. Returns ------- @@ -248,8 +252,9 @@ class TMD: else: return False - def get_title_type(self): - """Gets the type of the TMD's associated title. + def get_title_type(self) -> str: + """ + Gets the type of the TMD's associated title. Can be one of several possible values: 'System', 'Game', 'Channel', 'SystemChannel', 'GameWithChannel', or 'HiddenChannel' @@ -279,7 +284,8 @@ class TMD: return "Unknown" def get_content_type(self): - """Gets the type of content contained in the TMD's associated title. + """ + Gets the type of content contained in the TMD's associated title. Can be one of several possible values: 'Normal', 'Development/Unknown', 'Hash Tree', 'DLC', or 'Shared' @@ -303,8 +309,9 @@ class TMD: case _: return "Unknown" - def get_content_record(self, record): - """Gets the content record at the specified index. + def get_content_record(self, record) -> ContentRecord: + """ + Gets the content record at the specified index. Parameters ---------- @@ -322,8 +329,9 @@ class TMD: raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) + "' contents but index was '" + str(record) + "'!") - def set_title_id(self, title_id): - """Sets the Title ID of the title in the ticket. + def set_title_id(self, title_id) -> None: + """ + Sets the Title ID of the title in the ticket. Parameters ---------- diff --git a/src/libWiiPy/types.py b/src/libWiiPy/types.py index 3a9f0ff..6080dbc 100644 --- a/src/libWiiPy/types.py +++ b/src/libWiiPy/types.py @@ -7,7 +7,9 @@ from dataclasses import dataclass @dataclass class ContentRecord: """ - Creates a content record object that contains the details of a content contained in a title. + A content record object that contains the details of a content contained in a title. This information must match + the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the + decrypted data will not match the hash in the content record. Attributes ---------- @@ -31,7 +33,10 @@ class ContentRecord: @dataclass class TitleLimit: - """Creates a TitleLimit object that contains the type of restriction and the limit. + """ + A TitleLimit object that contains the type of restriction and the limit. The limit type can be one of the following: + 0 = None, 1 = Time Limit, 3 = None, or 4 = Launch Count. The maximum usage is then either the time in minutes the + title can be played or the maximum number of launches allowed for that title, based on the type of limit applied. Attributes ---------- @@ -40,9 +45,8 @@ class TitleLimit: maximum_usage : int The maximum value for the type of play limit applied. """ - # The type of play limit applied. The following types exist: + # The type of play limit applied. # 0 = None, 1 = Time Limit, 3 = None, 4 = Launch Count limit_type: int # The maximum value of the limit applied. - # This is either the number of minutes for a time limit, or the number of launches for a launch limit. maximum_usage: int diff --git a/src/libWiiPy/wad.py b/src/libWiiPy/wad.py index f98c323..cd8423b 100644 --- a/src/libWiiPy/wad.py +++ b/src/libWiiPy/wad.py @@ -10,7 +10,24 @@ from .shared import align_value, pad_bytes_stream class WAD: """ - Creates a WAD object that allows for either loading and editing an existing WAD or creating a new WAD from raw data. + A WAD object that allows for either loading and editing an existing WAD or creating a new WAD from raw data. + + Attributes + ---------- + wad_type : str + The type of WAD, either ib for boot2 or Is for normal installable WADs. libWiiPy only supports Is currently. + wad_cert_size : int + The size of the WAD's certificate. + wad_crl_size : int + The size of the WAD's crl. + wad_tik_size : int + The size of the WAD's Ticket. + wad_tmd_size : int + The size of the WAD's TMD. + wad_content_size : int + The size of WAD's total content region. + wad_meta_size : int + The size of the WAD's meta/footer. """ def __init__(self): self.wad_hdr_size: int = 64 @@ -33,7 +50,8 @@ class WAD: self.wad_meta_data: bytes = b'' def load(self, wad_data) -> None: - """Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already + """ + Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already existing WAD file. Parameters @@ -114,7 +132,8 @@ class WAD: self.wad_meta_data = wad_data.read(self.wad_meta_size) def dump(self) -> bytes: - """Dumps the WAD object into the raw WAD file. This allows for creating a WAD file from the data contained in + """ + Dumps the WAD object into the raw WAD file. This allows for creating a WAD file from the data contained in the WAD object. Returns @@ -167,8 +186,9 @@ class WAD: # Return the raw WAD file for the data contained in the object. return wad_data_raw - def get_wad_type(self): - """Gets the type of the WAD. + def get_wad_type(self) -> str: + """ + Gets the type of the WAD. Returns ------- @@ -178,7 +198,8 @@ class WAD: return self.wad_type def get_cert_data(self) -> bytes: - """Gets the certificate data from the WAD. + """ + Gets the certificate data from the WAD. Returns ------- @@ -188,7 +209,8 @@ class WAD: return self.wad_cert_data def get_crl_data(self) -> bytes: - """Gets the crl data from the WAD, if it exists. + """ + Gets the crl data from the WAD, if it exists. Returns ------- @@ -198,7 +220,8 @@ class WAD: return self.wad_crl_data def get_ticket_data(self) -> bytes: - """Gets the ticket data from the WAD. + """ + Gets the ticket data from the WAD. Returns ------- @@ -208,7 +231,8 @@ class WAD: return self.wad_tik_data def get_tmd_data(self) -> bytes: - """Returns the TMD data from the WAD. + """ + Returns the TMD data from the WAD. Returns ------- @@ -218,7 +242,8 @@ class WAD: return self.wad_tmd_data def get_content_data(self) -> bytes: - """Gets the content of the WAD. + """ + Gets the content of the WAD. Returns ------- @@ -228,7 +253,8 @@ class WAD: return self.wad_content_data def get_meta_data(self) -> bytes: - """Gets the meta region of the WAD, which is typically unused. + """ + Gets the meta region of the WAD, which is typically unused. Returns ------- @@ -238,7 +264,8 @@ class WAD: return self.wad_meta_data def set_cert_data(self, cert_data) -> None: - """Sets the certificate data of the WAD. Also calculates the new size. + """ + Sets the certificate data of the WAD. Also calculates the new size. Parameters ---------- @@ -250,7 +277,8 @@ class WAD: self.wad_cert_size = len(cert_data) def set_crl_data(self, crl_data) -> None: - """Sets the crl data of the WAD. Also calculates the new size. + """ + Sets the crl data of the WAD. Also calculates the new size. Parameters ---------- @@ -262,7 +290,8 @@ class WAD: self.wad_crl_size = len(crl_data) def set_tmd_data(self, tmd_data) -> None: - """Sets the TMD data of the WAD. Also calculates the new size. + """ + Sets the TMD data of the WAD. Also calculates the new size. Parameters ---------- @@ -274,7 +303,8 @@ class WAD: self.wad_tmd_size = len(tmd_data) def set_ticket_data(self, tik_data) -> None: - """Sets the Ticket data of the WAD. Also calculates the new size. + """ + Sets the Ticket data of the WAD. Also calculates the new size. Parameters ---------- @@ -286,7 +316,8 @@ class WAD: self.wad_tik_size = len(tik_data) def set_content_data(self, content_data) -> None: - """Sets the content data of the WAD. Also calculates the new size. + """ + Sets the content data of the WAD. Also calculates the new size. Parameters ---------- @@ -298,7 +329,8 @@ class WAD: self.wad_content_size = len(content_data) def set_meta_data(self, meta_data) -> None: - """Sets the meta data of the WAD. Also calculates the new size. + """ + Sets the meta data of the WAD. Also calculates the new size. Parameters ----------