11 Commits

Author SHA1 Message Date
1b6e0db26d Revert changes related to processing content indices
Changes released in libWiiPy v0.5.0 and v0.5.1 to how indices were handled ended up way overcomplicating things, resulting in lots of issues now that I'm working with the content module again in WiiPy. These changes have mostly been reverted.
The issues were related to handling WADs where the content indices don't align with the actual index of the content, like in cases where content has bene removed. This issue has been fixed again with a new and much simpler patch that should not introduce new bugs.
2024-10-20 19:03:26 -04:00
9ae059b797 Add support for extracting/packing/otherwise handling dev WADs 2024-10-13 21:39:52 -04:00
c604c195d2 Correct line endings when dumping setting.txt 2024-10-09 20:38:00 -04:00
0c2e13f18a Remove leftover debugging print 2024-10-08 14:06:09 -04:00
7fed039fdc Added methods to get a content index from a CID and add content from a Title() 2024-09-13 14:56:37 -04:00
0d306076a2 Added methods to content module to remove contents by index or CID 2024-09-11 11:13:01 -04:00
a1773b9a02 Improved adding new content to title, fixed minor bugs
Dumping a title now properly updates the "number of contents" field in the TMD, so you're able to add more content than there was previously, and that new content will be added correctly.
2024-09-08 13:15:52 -04:00
7c2f0fb21f Fix getting a title's type 2024-09-05 11:07:14 -04:00
0edd4fa6bb Update how TMD regions are handled 2024-09-04 14:28:13 -04:00
e163d34f0b Allow for calculating title size with and without shared content 2024-09-04 14:23:24 -04:00
9fb0fdbc17 Added setting.txt parser, moved some modules under a new "nand" subpackage 2024-08-14 01:26:46 -04:00
16 changed files with 439 additions and 193 deletions

View File

@@ -6,6 +6,7 @@
:maxdepth: 4
libWiiPy.archive
libWiiPy.nand
libWiiPy.title
```

View File

@@ -0,0 +1,27 @@
# libWiiPy.nand package
## Submodules
### libWiiPy.nand.emunand module
```{eval-rst}
.. automodule:: libWiiPy.nand.emunand
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.nand.setting module
```{eval-rst}
.. automodule:: libWiiPy.nand.setting
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.nand.sys module
```{eval-rst}
.. automodule:: libWiiPy.nand.sys
:members:
:undoc-members:
:show-inheritance:
```

View File

@@ -26,14 +26,6 @@
:show-inheritance:
```
### libWiiPy.title.emunand module
```{eval-rst}
.. automodule:: libWiiPy.title.emunand
:members:
:undoc-members:
:show-inheritance:
```
### libWiipy.title.iospatcher module
```{eval-rst}
.. automodule:: libWiiPy.title.iospatcher
@@ -50,14 +42,6 @@
:show-inheritance:
```
### libWiiPy.title.sys module
```{eval-rst}
.. automodule:: libWiiPy.title.sys
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.ticket module
```{eval-rst}
.. automodule:: libWiiPy.title.ticket

View File

@@ -1,6 +1,6 @@
[project]
name = "libWiiPy"
version = "0.5.0"
version = "0.5.2"
authors = [
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }

View File

@@ -3,7 +3,8 @@
#
# These are the essential submodules from libWiiPy that you'd probably want imported by default.
__all__ = ["archive", "title"]
__all__ = ["archive", "nand", "title"]
from . import archive
from . import nand
from . import title

View File

@@ -0,0 +1,6 @@
# "nand/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .emunand import *
from .setting import *
from .sys import *

View File

@@ -1,4 +1,4 @@
# "title/emunand.py" from libWiiPy by NinjaCheetah & Contributors
# "nand/emunand.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# Code for handling setting up and modifying a Wii EmuNAND.
@@ -6,8 +6,8 @@
import os
import pathlib
import shutil
from .title import Title
from .content import SharedContentMap as _SharedContentMap
from ..title.title import Title
from ..title.content import SharedContentMap as _SharedContentMap
from .sys import UidSys as _UidSys

View File

@@ -0,0 +1,134 @@
# "nand/setting.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki//title/00000001/00000002/data/setting.txt for information about setting.txt.
import io
from ..shared import _pad_bytes
_key = 0x73B5DBFA
class SettingTxt:
"""
A SettingTxt object that allows for decrypting and then parsing a setting.txt file from the Wii.
Attributes
----------
area : str
The region of the System Menu this file matches with.
model : str
The model of the console, usually RVL-001 or RVL-101.
dvd : int
Unknown, might have to do with indicating support for scrapped DVD playback capabilities.
mpch : str
Unknown, generally accepted value is "0x7FFE".
code : str
Unknown code, may match with manufacturer code in serial number?
serial_number : str
Serial number of the console.
video : str
Video mode, either NTSC or PAL.
game : str
Another region code, possibly set by the hidden region select channel.
"""
def __init__(self):
self.area: str = ""
self.model: str = ""
self.dvd: int = 0
self.mpch: str = "" # What does this mean, Movie Player Channel? It's also a hex string, it seems.
self.code: str = ""
self.serial_number: str = ""
self.video: str = ""
self.game: str = ""
def load(self, setting_txt: bytes) -> None:
"""
Loads the raw data of an encrypted setting.txt file and decrypts it to parse its arguments
Parameters
----------
setting_txt : bytes
The data of an encrypted setting.txt file.
"""
with io.BytesIO(setting_txt) as setting_data:
global _key # I still don't actually know what *kind* of encryption this is.
setting_txt_dec: [int] = []
for i in range(0, 256):
setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
_key = (_key << 1) | (_key >> 31)
setting_txt_dec = bytes(setting_txt_dec)
try:
setting_str = setting_txt_dec.decode('utf-8')
except UnicodeDecodeError:
last_newline_pos = setting_txt_dec.rfind(b'\n') # This makes sure we don't try to decode any garbage data.
setting_str = setting_txt_dec[:last_newline_pos + 1].decode('utf-8')
self.load_decrypted(setting_str)
def load_decrypted(self, setting_txt: str) -> None:
"""
Loads the raw data of a decrypted setting.txt file and parses its arguments
Parameters
----------
setting_txt : str
The data of a decrypted setting.txt file.
"""
setting_dict = {}
# Iterate over every key in the file to create a dictionary.
for line in setting_txt.splitlines():
line = line.strip()
if line is not None:
key, value = line.split('=', 1)
setting_dict[key.strip()] = value.strip()
# Load the values from the dictionary into the object.
self.area = setting_dict["AREA"]
self.model = setting_dict["MODEL"]
self.dvd = int(setting_dict["DVD"])
self.mpch = setting_dict["MPCH"]
self.code = setting_dict["CODE"]
self.serial_number = setting_dict["SERNO"]
self.video = setting_dict["VIDEO"]
self.game = setting_dict["GAME"]
def dump(self) -> bytes:
"""
Dumps the SettingTxt object back into an encrypted bytes that the Wii can load.
Returns
-------
bytes
The setting.txt file as encrypted bytes.
"""
setting_str = self.dump_decrypted()
setting_txt_dec = setting_str.encode()
global _key
# This could probably be made more efficient somehow.
setting_txt_enc: [int] = []
with io.BytesIO(setting_txt_dec) as setting_data:
for i in range(0, len(setting_txt_dec)):
setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
_key = (_key << 1) | (_key >> 31)
setting_txt_enc = _pad_bytes(bytes(setting_txt_enc), 256)
return setting_txt_enc
def dump_decrypted(self) -> str:
"""
Dumps the SettingTxt object into a decrypted string.
Returns
-------
str
The setting.txt file as decrypted text.
"""
# Write the keys back into a text file that can then be manually edited or re-encrypted.
setting_txt = ""
setting_txt += f"AREA={self.area}\r\n"
setting_txt += f"MODEL={self.model}\r\n"
setting_txt += f"DVD={self.dvd}\r\n"
setting_txt += f"MPCH={self.mpch}\r\n"
setting_txt += f"CODE={self.code}\r\n"
setting_txt += f"SERNO={self.serial_number}\r\n"
setting_txt += f"VIDEO={self.video}\r\n"
setting_txt += f"GAME={self.game}\r\n"
return setting_txt

View File

@@ -1,4 +1,4 @@
# "title/sys.py" from libWiiPy by NinjaCheetah & Contributors
# "nand/sys.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki//sys/uid.sys for information about uid.sys.
@@ -28,7 +28,7 @@ class _UidSysEntry:
class UidSys:
"""
A UidSys object to parse and edit the uid.sys file stored in /sys/ on the Wii's NAND. This file is used to track all
the titles installed on the console.
the titles that have been launched on a console.
Attributes
----------

View File

@@ -3,10 +3,8 @@
from .content import *
from .crypto import *
from .emunand import *
from .iospatcher import *
from .nus import *
from .sys import *
from .ticket import *
from .title import *
from .tmd import *

View File

@@ -7,11 +7,14 @@ common_key = 'ebe42a225e8593e448d9c5457381aaf7'
korean_key = '63b82bb4f4614e2e13f2fefbba4c9b7e'
vwii_key = '30bfc76e7c19afbb23163330ced7c28d'
development_key = 'a1604a6a7123b529ae8bec32c816fcaa'
def get_common_key(common_key_index) -> bytes:
def get_common_key(common_key_index, dev=False) -> bytes:
"""
Gets the specified Wii Common Key based on the index provided. If an invalid common key index is provided, this
function falls back on always returning key 0 (the Common Key).
function falls back on always returning key 0 (the Common Key). If the kwarg "dev" is specified, then key 0 will
point to the development common key rather than the retail one. Keys 1 and 2 are unaffected by this argument.
Possible values for common_key_index: 0: Common Key, 1: Korean Key, 2: vWii Key
@@ -19,6 +22,8 @@ def get_common_key(common_key_index) -> bytes:
----------
common_key_index : int
The index of the common key to be returned.
dev : bool
If the dev keys should be used in place of the retail keys. Only affects key 0.
Returns
-------
@@ -27,7 +32,10 @@ def get_common_key(common_key_index) -> bytes:
"""
match common_key_index:
case 0:
common_key_bin = binascii.unhexlify(common_key)
if dev:
common_key_bin = binascii.unhexlify(development_key)
else:
common_key_bin = binascii.unhexlify(common_key)
case 1:
common_key_bin = binascii.unhexlify(korean_key)
case 2:

View File

@@ -8,11 +8,19 @@ import io
import hashlib
from typing import List
from dataclasses import dataclass as _dataclass
from enum import IntEnum as _IntEnum
from ..types import _ContentRecord
from ..shared import _pad_bytes, _align_value
from .crypto import decrypt_content, encrypt_content
class ContentType(_IntEnum):
NORMAL = 1
HASH_TREE = 3
DLC = 16385
SHARED = 32769
class ContentRegion:
"""
A ContentRegion object to parse the continuous content region of a WAD. Allows for retrieving content from the
@@ -109,10 +117,6 @@ class ContentRegion:
"""
Gets an individual content from the content region based on the provided index, in encrypted form.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
index : int
@@ -123,17 +127,10 @@ class ContentRegion:
bytes
The encrypted content listed in the content record.
"""
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to get the content at index " + str(index) + ", but no content with that "
"index exists!")
# This is the literal index in the list of content that we're going to get.
target_index = current_indices.index(index)
content_enc = self.content_list[target_index]
if index >= self.num_contents:
raise ValueError(f"You are trying to get the content at index {index}, but no content with that "
f"index exists!")
content_enc = self.content_list[index]
return content_enc
def get_enc_content_by_cid(self, cid: int) -> bytes:
@@ -150,16 +147,11 @@ class ContentRegion:
bytes
The encrypted content listed in the content record.
"""
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
"that ID exists!")
# Get the content index associated with the CID we now know exists.
target_index = content_ids.index(cid)
content_index = self.content_records[target_index].index
try:
content_index = self.get_index_from_cid(cid)
except ValueError:
raise ValueError(f"You are trying to get a content with Content ID {cid}, "
f"but no content with that ID exists!")
content_enc = self.get_enc_content_by_index(content_index)
return content_enc
@@ -178,14 +170,10 @@ class ContentRegion:
"""
Gets an individual content from the content region based on the provided index, in decrypted form.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
index : int
The content index of the content you want to get.
The index of the content you want to get.
title_key : bytes
The Title Key for the title the content is from.
skip_hash : bool, optional
@@ -196,19 +184,14 @@ class ContentRegion:
bytes
The decrypted content listed in the content record.
"""
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
# This is the literal index in the list of content that we're going to get.
target_index = current_indices.index(index)
# Get the content index in the Content Record to ensure decryption works properly.
cnt_index = self.content_records[index].index
content_enc = self.get_enc_content_by_index(index)
content_dec = decrypt_content(content_enc, title_key, index, self.content_records[target_index].content_size)
content_dec = decrypt_content(content_enc, title_key, cnt_index, self.content_records[index].content_size)
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
# If it does not, then something has gone wrong in the decryption, and an error will be thrown.
content_dec_hash = hashlib.sha1(content_dec).hexdigest()
content_record_hash = str(self.content_records[target_index].content_hash.decode())
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 != content_record_hash:
if skip_hash:
@@ -238,16 +221,11 @@ class ContentRegion:
bytes
The decrypted content listed in the content record.
"""
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
"that ID exists!")
# Get the content index associated with the CID we now know exists.
target_index = content_ids.index(cid)
content_index = self.content_records[target_index].index
try:
content_index = self.get_index_from_cid(cid)
except ValueError:
raise ValueError(f"You are trying to get a content with Content ID {cid}, "
f"but no content with that ID exists!")
content_dec = self.get_content_by_index(content_index, title_key, skip_hash)
return content_dec
@@ -273,6 +251,29 @@ class ContentRegion:
dec_contents.append(self.get_content_by_index(content, title_key, skip_hash))
return dec_contents
def get_index_from_cid(self, cid: int) -> int:
"""
Gets the index of a content by its Content ID.
Parameters
----------
cid : int
The Content ID to get the index of.
Returns
-------
int
The content index.
"""
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("The specified Content ID does not exist!")
index = content_ids.index(cid)
return index
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
content_hash: bytes) -> None:
"""
@@ -286,7 +287,7 @@ class ContentRegion:
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
The index used when encrypting the new content.
content_type : int
The type of the new content.
content_size : int
@@ -303,11 +304,13 @@ class ContentRegion:
# If we're good, then append all the data and create a new ContentRecord().
self.content_list.append(enc_content)
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
self.num_contents += 1
def add_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:
"""
Adds a new decrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
content size, and content hash to a new record in the ContentRecord list.
Adds a new decrypted content to the end of the ContentRegion, and adds the provided Content ID, content type,
content size, and content hash to a new record in the ContentRecord list. The index will be automatically
assigned by incrementing the current highest index in the records.
This first gets the content hash and size from the provided data, and then encrypts the content with the
provided Title Key before adding it to the ContentRegion.
@@ -318,13 +321,16 @@ class ContentRegion:
The new decrypted content to add.
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 other content in the ContentRegion.
"""
# Find the current highest content index and increment it for this content.
content_indices = []
for record in self.content_records:
content_indices.append(record.index)
index = max(content_indices) + 1
content_size = len(dec_content)
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
enc_content = encrypt_content(dec_content, title_key, index)
@@ -335,18 +341,14 @@ class ContentRegion:
"""
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
content size are set in the corresponding content record. A new Content ID or content type can also be
specified, but if it isn't than the current values are preserved.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
specified, but if it isn't then the current values are preserved.
Parameters
----------
enc_content : bytes
The new encrypted content to set.
index : int
The target content index to set the new content at.
The target index to set the new content at.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
@@ -356,34 +358,27 @@ class ContentRegion:
content_type : int, optional
The type of the new content. Current value will be preserved if not set.
"""
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to set the content at index " + str(index) + ", but no content with that "
"index currently exists!")
# This is the literal index in the list of content/content records that we're going to change.
target_index = current_indices.index(index)
if index >= self.num_contents:
raise ValueError(f"You are trying to set the content at index {index}, but no content with that "
f"index currently exists!")
# Reassign the values, but only set the optional ones if they were passed.
self.content_records[target_index].content_size = content_size
self.content_records[target_index].content_hash = content_hash
self.content_records[index].content_size = content_size
self.content_records[index].content_hash = content_hash
if cid is not None:
self.content_records[target_index].content_id = cid
self.content_records[index].content_id = cid
if content_type is not None:
self.content_records[target_index].content_type = content_type
self.content_records[index].content_type = content_type
# Add blank entries to the list to ensure that its length matches the length of the content record list.
while len(self.content_list) < len(self.content_records):
self.content_list.append(b'')
self.content_list[target_index] = enc_content
self.content_list[index] = enc_content
def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int = None,
content_type: int = None) -> None:
"""
Sets the content at the provided content index to the provided new decrypted content. The hash and content size
of this content will be generated and then set in the corresponding content record. A new Content ID or content
type can also be specified, but if it isn't than the current values are preserved.
type can also be specified, but if it isn't then the current values are preserved.
The provided Title Key is used to encrypt the content so that it can be set in the ContentRegion.
@@ -404,8 +399,9 @@ class ContentRegion:
content_size = len(dec_content)
# Calculate the hash of the new content.
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)
# Encrypt the content using the provided Title Key and the index from the Content Record, to ensure that
# encryption will succeed even if the provided index doesn't match the content's index.
enc_content = encrypt_content(dec_content, title_key, self.content_records[index].index)
# Pass values to set_enc_content()
self.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
@@ -415,10 +411,6 @@ class ContentRegion:
it matches the record at that index. Not recommended for most use cases, use decrypted content and
load_content() instead.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
enc_content : bytes
@@ -426,20 +418,13 @@ class ContentRegion:
index : int
The content index to load the content at.
"""
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
"index currently exists! Make sure the correct content records have been loaded.")
if index >= self.num_contents:
raise ValueError(f"You are trying to load the content at index {index}, but no content with that "
f"index currently exists! Make sure the correct content records have been loaded.")
# Add blank entries to the list to ensure that its length matches the length of the content record list.
while len(self.content_list) < len(self.content_records):
self.content_list.append(b'')
# This is the literal index in the list of content/content records that we're going to change.
target_index = current_indices.index(index)
self.content_list[target_index] = enc_content
self.content_list[index] = enc_content
def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None:
"""
@@ -447,32 +432,21 @@ class ContentRegion:
sure that it matches the corresponding record. This content will then be encrypted using the provided Title Key
before being loaded.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
dec_content : bytes
The decrypted content to load.
index : int
The content index to load the content at.
The index to load the content at.
title_key: bytes
The Title Key that matches the decrypted content.
"""
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
"index currently exists! Make sure the correct content records have been loaded.")
# This is the literal index in the list of content/content records that we're going to change.
target_index = current_indices.index(index)
if index >= self.num_contents:
raise ValueError(f"You are trying to load the content at index {index}, but no content with that "
f"index currently exists! Make sure the correct content records have been loaded.")
# 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[target_index].content_hash.decode():
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))
@@ -480,11 +454,47 @@ class ContentRegion:
while len(self.content_list) < len(self.content_records):
self.content_list.append(b'')
# If the hash matches, encrypt the content and set it where it belongs.
# This uses the index from the content records instead of just the index given, because there are some strange
# circumstances where the actual index in the array and the assigned content index don't match up, and this
# needs to accommodate that. Seems to only apply to custom WADs ? (Like cIOS WADs?)
enc_content = encrypt_content(dec_content, title_key, index)
self.content_list[target_index] = enc_content
# This uses the index from the content records instead of just the index given, because there are some poorly
# made custom WADs out there that don't have the contents in order, for whatever reason.
enc_content = encrypt_content(dec_content, title_key, self.content_records[index].index)
self.content_list[index] = enc_content
def remove_content_by_index(self, index: int) -> None:
"""
Removes the content at the specified index from the ContentRegion and content records.
This will allow gaps to be left in content indices, however this should not cause any issues.
Parameters
----------
index : int
The index of the content you want to remove.
"""
if index >= self.num_contents:
raise ValueError(f"You are trying to remove the content at index {index}, but no content with "
f"that index currently exists!")
# Delete the target index from both the content list and content records.
self.content_list.pop(index)
self.content_records.pop(index)
self.num_contents -= 1
def remove_content_by_cid(self, cid: int) -> None:
"""
Removes the content with the specified Content ID from the ContentRegion and content records.
This will allow gaps to be left in content indices, however this should not cause any issues.
Parameters
----------
cid : int
The Content ID of the content you want to remove.
"""
try:
index = self.get_index_from_cid(cid)
except ValueError:
raise ValueError(f"You are trying to remove content with Content ID {cid}, "
f"but no content with that ID exists!")
self.remove_content_by_index(index)
@_dataclass

View File

@@ -30,7 +30,7 @@ def _convert_tid_to_iv(title_id: str | bytes) -> bytes:
return title_key_iv
def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: bytes | str, dev=False) -> bytes:
"""
Gets the decrypted version of the encrypted Title Key provided.
@@ -44,6 +44,8 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
The index of the common key used to encrypt the Title Key.
title_id : bytes, str
The Title ID of the title that the key is for.
dev : bool
Whether the Title Key is encrypted with the development key or not.
Returns
-------
@@ -51,7 +53,7 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
The decrypted Title Key.
"""
# Load the correct common key for the title.
common_key = get_common_key(common_key_index)
common_key = get_common_key(common_key_index, dev)
# Convert the IV into the correct format based on the type provided.
title_key_iv = _convert_tid_to_iv(title_id)
# The IV will always be in the same format by this point, so add the last 8 bytes.
@@ -63,7 +65,7 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
return title_key
def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str, dev=False) -> bytes:
"""
Encrypts the provided Title Key with the selected common key.
@@ -77,6 +79,8 @@ def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: byt
The index of the common key used to encrypt the Title Key.
title_id : bytes, str
The Title ID of the title that the key is for.
dev : bool
Whether the Title Key is encrypted with the development key or not.
Returns
-------
@@ -84,7 +88,7 @@ def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: byt
An encrypted Title Key.
"""
# Load the correct common key for the title.
common_key = get_common_key(common_key_index)
common_key = get_common_key(common_key_index, dev)
# Convert the IV into the correct format based on the type provided.
title_key_iv = _convert_tid_to_iv(title_id)
# The IV will always be in the same format by this point, so add the last 8 bytes.

View File

@@ -40,6 +40,9 @@ class Ticket:
Attributes
----------
is_dev : bool
Whether this Ticket is signed for development or not, and whether the Title Key is encrypted for development
or not.
signature : bytes
The signature applied to the ticket.
ticket_version : int
@@ -56,6 +59,8 @@ class Ticket:
The index of the common key required to decrypt this ticket's Title Key.
"""
def __init__(self):
# If this is a dev ticket
self.is_dev: bool = False # Defaults to false, set to true during load if this ticket is using dev certs.
# Signature blob header
self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048
self.signature: bytes = b'' # Actual signature data
@@ -155,6 +160,11 @@ class Ticket:
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))
# Check certs to see if this is a retail or dev ticket. Treats unknown certs as being retail for now.
if self.signature_issuer.find("Root-CA00000002-XS00000006") != -1:
self.is_dev = True
else:
self.is_dev = False
def dump(self) -> bytes:
"""
@@ -315,7 +325,7 @@ class Ticket:
bytes
The decrypted title key.
"""
title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id)
title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id, self.is_dev)
return title_key
def set_title_id(self, title_id) -> None:

View File

@@ -76,7 +76,9 @@ class Title:
if self.tmd.title_id == "0000000100000001":
self.wad.wad_type = "ib"
# Dump the TMD and set it in the WAD.
self.tmd.content_records = self.content.content_records
# This requires updating the content records and number of contents in the TMD first.
self.tmd.content_records = self.content.content_records # This may not be needed because it's a ref already
self.tmd.num_contents = len(self.content.content_records)
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())
@@ -117,8 +119,9 @@ class Title:
"""
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.
# Load the content records into the ContentRegion object, and update the number of contents.
self.content.content_records = self.tmd.content_records
self.content.num_contents = self.tmd.num_contents
def set_title_id(self, title_id: str) -> None:
"""
@@ -135,7 +138,8 @@ class Title:
self.tmd.set_title_id(title_id)
title_key_decrypted = self.ticket.get_title_key()
self.ticket.set_title_id(title_id)
title_key_encrypted = encrypt_title_key(title_key_decrypted, self.ticket.common_key_index, title_id)
title_key_encrypted = encrypt_title_key(title_key_decrypted, self.ticket.common_key_index, title_id,
self.ticket.is_dev)
self.ticket.title_key_enc = title_key_encrypted
def set_title_version(self, title_version: str | int) -> None:
@@ -190,9 +194,15 @@ class Title:
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
return dec_content
def get_title_size(self) -> int:
def get_title_size(self, absolute=False) -> int:
"""
Gets the installed size of the title, including the TMD and Ticket, in bytes.
Gets the installed size of the title, including the TMD and Ticket, in bytes. The "absolute" option determines
whether shared content sizes should be included in the total size or not. This option defaults to False.
Parameters
----------
absolute : bool, optional
Whether shared contents should be included in the total size or not. Defaults to False.
Returns
-------
@@ -207,34 +217,90 @@ class Title:
# For contents, get their sizes from the content records, because they store the intended sizes of the decrypted
# contents, which are usually different from the encrypted sizes.
for record in self.content.content_records:
title_size += record.content_size
if record.content_type == 32769:
if absolute:
title_size += record.content_size
else:
title_size += record.content_size
return title_size
def get_title_size_blocks(self) -> int:
def get_title_size_blocks(self, absolute=False) -> int:
"""
Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format.
Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format. The
"absolute" option determines whether shared content sizes should be included in the total size or not. This
option defaults to False.
1 Wii block is equal to 128KiB, and if any amount of a block is used, the entire block is considered used.
Parameters
----------
absolute : bool, optional
Whether shared contents should be included in the total size or not. Defaults to False.
Returns
-------
int
The installed size of the title, in blocks.
"""
title_size_bytes = self.get_title_size()
title_size_bytes = self.get_title_size(absolute)
blocks = math.ceil(title_size_bytes / 131072)
return blocks
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
content_hash: bytes) -> None:
"""
Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
content size, and content hash to a new record in the ContentRecord list.
Parameters
----------
enc_content : bytes
The new encrypted content to add.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index used when encrypting the new content.
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.
"""
# Add the encrypted content.
self.content.add_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 add_content(self, dec_content: bytes, cid: int, content_type: int) -> None:
"""
Adds a new decrypted content to the end of the ContentRegion, and adds the provided Content ID, content type,
content size, and content hash to a new record in the ContentRecord list. The index will be automatically
assigned by incrementing the current highest index in the records.
This first gets the content hash and size from the provided data, and then encrypts the content with the
Title Key before adding it to the ContentRegion.
Parameters
----------
dec_content : bytes
The new decrypted content to add.
cid : int
The Content ID to assign the new content in the content record.
content_type : int
The type of the new content.
"""
# Add the decrypted content.
self.content.add_content(dec_content, cid, content_type, self.ticket.get_title_key())
# Update the TMD to match.
self.tmd.content_records = self.content.content_records
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
content_type: int = None) -> None:
"""
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
content size are set in the corresponding content record. A new Content ID or content type can also be
specified, but if it isn't than the current values are preserved.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Sets the content at the provided index to the provided new encrypted content. The provided hash and content size
are set in the corresponding content record. A new Content ID or content type can also be specified, but if it
isn't then the current values are preserved.
This also updates the content records in the TMD after the content is set.
@@ -260,9 +326,9 @@ class Title:
def set_content(self, dec_content: bytes, index: int, cid: int = None, content_type: int = None) -> None:
"""
Sets the content at the provided content index to the provided new decrypted content. The hash and content size
of this content will be generated and then set in the corresponding content record. A new Content ID or content
type can also be specified, but if it isn't than the current values are preserved.
Sets the content at the provided index to the provided new decrypted content. The hash and content size of this
content will be generated and then set in the corresponding content record. A new Content ID or content type can
also be specified, but if it isn't then the current values are preserved.
This also updates the content records in the TMD after the content is set.
@@ -288,16 +354,12 @@ class Title:
sure that it matches the corresponding record. This content will then be encrypted using the title's Title Key
before being loaded.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
dec_content : bytes
The decrypted content to load.
index : int
The content index to load the content at.
The index to load the content at.
"""
# Load the decrypted content.
self.content.load_content(dec_content, index, self.ticket.get_title_key())
@@ -314,6 +376,7 @@ class Title:
after any changes to the TMD or Ticket, and before dumping the Title object into a WAD to ensure that the WAD
is properly fakesigned.
"""
self.tmd.num_contents = self.content.num_contents # This needs to be updated in case it was changed
self.tmd.fakesign()
self.ticket.fakesign()

View File

@@ -8,7 +8,7 @@ import binascii
import hashlib
import struct
from typing import List
from enum import IntEnum
from enum import IntEnum as _IntEnum
from ..types import _ContentRecord
from ..shared import _bitmask
from .util import title_ver_dec_to_standard, title_ver_standard_to_dec
@@ -37,7 +37,7 @@ class TMD:
self.blob_header: bytes = b''
self.signature_type: int = 0
self.signature: bytes = b''
self.issuer: bytes = b'' # Follows the format "Root-CA%08x-CP%08x"
self.signature_issuer: str = "" # 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 # Certificate Authority Certificate Revocation List version
self.signer_crl_version: int = 0 # Certificate Policy Certificate Revocation List version
@@ -45,7 +45,7 @@ class TMD:
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.title_type: str = "" # The type of the associated title.
self.title_type: bytes = b'' # The type of the associated title. Should always be 00000001 in a Wii TMD.
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'' # The parental controls rating of the associated title.
@@ -82,7 +82,7 @@ class TMD:
self.signature = tmd_data.read(256)
# Signing certificate issuer.
tmd_data.seek(0x140)
self.issuer = tmd_data.read(64)
self.signature_issuer = str(tmd_data.read(64).decode())
# 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))
@@ -107,11 +107,10 @@ class TMD:
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 the title. This is an internal property used to show if this title is for the ill-fated
# NetCard (0), or the Wii (1), and is therefore always 1 for Wii TMDs.
tmd_data.seek(0x194)
content_type_bin = tmd_data.read(4)
content_type_hex = binascii.hexlify(content_type_bin)
self.title_type = str(content_type_hex.decode())
self.title_type = tmd_data.read(4)
# Publisher of the title.
tmd_data.seek(0x198)
self.group_id = int.from_bytes(tmd_data.read(2))
@@ -175,7 +174,7 @@ class TMD:
# Padding to 64 bytes.
tmd_data += b'\x00' * 60
# Signing certificate issuer.
tmd_data += self.issuer
tmd_data += str.encode(self.signature_issuer)
# TMD version.
tmd_data += int.to_bytes(self.tmd_version, 1)
# Certificate Authority CRL version.
@@ -188,8 +187,8 @@ class TMD:
tmd_data += binascii.unhexlify(self.ios_tid)
# Title's Title ID.
tmd_data += binascii.unhexlify(self.title_id)
# Content type.
tmd_data += binascii.unhexlify(self.title_type)
# Title type.
tmd_data += self.title_type
# Group ID.
tmd_data += int.to_bytes(self.group_id, 2)
# 2 bytes of zero for reasons.
@@ -280,10 +279,11 @@ class TMD:
def get_title_region(self) -> str:
"""
Gets the region of the TMD's associated title.
Gets the system region specified in the TMD. This is not necessarily the true region of the title, but is the
hardware region that this title is designed and allowed to be run on.
Can be one of several possible values:
'Japan', 'North America', 'Europe', 'World', or 'Korea'.
'JPN', 'USA', 'EUR', 'None', or 'KOR'.
Returns
-------
@@ -292,19 +292,19 @@ class TMD:
"""
match self.region:
case 0:
return "Japan"
return "JPN"
case 1:
return "North America"
return "USA"
case 2:
return "Europe"
return "EUR"
case 3:
return "World"
return "None"
case 4:
return "Korea"
return "KOR"
def get_title_type(self) -> str:
"""
Gets the type of the TMD's associated title.
Gets the type of the title this TMD describes. The title_type field is not related to these types.
Can be one of several possible values:
'System', 'Game', 'Channel', 'SystemChannel', 'GameChannel', or 'HiddenChannel'
@@ -314,7 +314,7 @@ class TMD:
str
The type of the title.
"""
match self.title_type:
match self.title_id[:8]:
case '00000001':
return "System"
case '00010000':
@@ -390,7 +390,7 @@ class TMD:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
"' contents but index was '" + str(record) + "'!")
class AccessFlags(IntEnum):
class AccessFlags(_IntEnum):
AHB = 0
DVD_VIDEO = 1