31 Commits

Author SHA1 Message Date
3a44eaf2cf Added new CertificateChain class to manage the certs in a chain
Some checks failed
Build and publish documentation with Sphinx / build (push) Has been cancelled
Build and publish documentation with Sphinx / deploy (push) Has been cancelled
2024-12-15 23:09:13 -05:00
2fdd808137 Added new cert module to parse certs and functions to use them for verification 2024-12-15 22:08:51 -05:00
f98a3703a4 Fixed EmuNAND title installs when the title wasn't loaded from a WAD 2024-12-12 22:29:33 -05:00
1e6952c2b2 Fixed invalid default value for nus.download_title() 2024-12-12 21:54:00 -05:00
944fb896b5 Exclude DLC size from size total in tmd module 2024-12-12 20:05:43 -05:00
3d4d3dc99e Use .lower() since title paths are lowercase 2024-12-12 17:32:44 -05:00
62f99165c7 Split methods to get the TMD and Ticket in EmuNAND module 2024-12-12 17:25:23 -05:00
e227f4e2be Added methods to get content size to TMD module 2024-12-12 11:47:15 -05:00
da16259938 Added method to EmuNAND module to get the TMD and Ticket for an installed title 2024-12-11 21:37:56 -05:00
1cce0f14ee Allow specifying a custom endpoint URL for NUS downloads 2024-12-08 22:06:32 -05:00
c86b44f35c Restructured IMET header code, now in archive/u8 and not media/banner 2024-11-30 18:54:50 -05:00
1ff4ecdf68 Added method to query all titles installed in EmuNAND 2024-11-28 00:49:54 -05:00
302bd842d1 Remove leftover import now that banner.py has been moved 2024-11-21 19:20:32 -05:00
c5a007e1f5 Small fix for docs 2024-11-21 19:18:03 -05:00
e96f6d9f13 Finished IMETHeader class, can now load, dump, create, and get/set channel names 2024-11-21 19:08:52 -05:00
57b2ed63d4 Disable TMD/Ticket TID match check 2024-11-21 16:22:12 -05:00
855200bb98 Add preliminary support for parsing 00000000.app
New module banner.py offers classes for IMD5 and IMET headers, U8 unpacker now supports U8 archives with IMET headers.
2024-11-18 17:00:18 -05:00
cfd105ba81 Updated banner used in README 2024-11-10 23:22:24 -05:00
ed7e928ad8 Updated banner 2024-11-10 23:20:30 -05:00
6b18254edc Small README change to reflect dev support 2024-10-20 22:41:34 -04:00
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
26 changed files with 1348 additions and 248 deletions

View File

@@ -1,4 +1,4 @@
![banner](https://github.com/NinjaCheetah/libWiiPy/assets/58050615/00ea4c41-673c-4a74-addb-fbb40b4313c8)
![banner](https://github.com/user-attachments/assets/eb30a500-6d27-42f1-bded-24221930a8e3)
# libWiiPy
libWiiPy is a modern Python 3 library for handling the various files and formats found on 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 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.
@@ -8,7 +8,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 and Ticket parsing/editing (`.tmd`, `.tik`)
- Title parsing/editing, including content encryption/decryption
- Title parsing/editing, including content encryption/decryption (both retail and development)
- WAD file parsing/editing (`.wad`)
- Downloading titles from the NUS
- Packing and unpacking U8 archives (`.app`, `.arc`)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 82 KiB

BIN
docs/source/banner_old.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@@ -0,0 +1,11 @@
# libWiiPy.media package
## Submodules
### libWiiPy.media.banner module
```{eval-rst}
.. automodule:: libWiiPy.media.banner
:members:
:undoc-members:
:show-inheritance:
```

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

@@ -2,6 +2,14 @@
## Submodules
### libWiiPy.title.cert module
```{eval-rst}
.. automodule:: libWiiPy.title.cert
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.commonkeys module
```{eval-rst}
.. automodule:: libWiiPy.title.commonkeys
@@ -26,14 +34,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 +50,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.6.0"
authors = [
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }

View File

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

View File

@@ -3,11 +3,14 @@
#
# See https://wiibrew.org/wiki/U8_archive for details about the U8 archive format.
import binascii
import hashlib
import io
import os
import pathlib
from enum import IntEnum as _IntEnum
from dataclasses import dataclass as _dataclass
from typing import List
from typing import List, Tuple
from ..shared import _align_value, _pad_bytes
@@ -36,13 +39,25 @@ class _U8Node:
class U8Archive:
def __init__(self):
"""
A U8 object that allows for managing the contents of a U8 archive.
"""
A U8 object that allows for parsing and editing the contents of a U8 archive.
Attributes
----------
"""
Attributes
----------
u8_node_list : List[_U8Node]
A list of U8Node objects representing the nodes of the U8 archive.
file_name_list : List[str]
A list of the names of the files in the U8 archive.
file_data_list : List[bytes]
A list of file data for the files in the U8 archive; corresponds with file_name_list.
header_size : int
The size of the U8 archive header.
data_offset : int
The offset of the data region of the U8 archive.
imet_header: IMETHeader
The IMET header of the U8 archive, if one exists. Otherwise, an empty IMETHeader object.
"""
def __init__(self):
self.u8_magic = b''
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
self.file_name_list: List[str] = []
@@ -51,6 +66,7 @@ class U8Archive:
self.header_size: int = 0
self.data_offset: int = 0
self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
self.imet_header: IMETHeader = IMETHeader()
def load(self, u8_data: bytes) -> None:
"""
@@ -66,7 +82,34 @@ class U8Archive:
u8_data.seek(0x0)
self.u8_magic = u8_data.read(4)
if self.u8_magic != b'\x55\xAA\x38\x2D':
raise TypeError("This is not a valid U8 archive!")
# Check for an IMET header, if the file doesn't start with the proper magic number. The header magic
# may be at either 0x40 or 0x80 depending on whether this title has a build tag at the start or not.
u8_data.seek(0x40)
self.u8_magic = u8_data.read(4)
if self.u8_magic == b'\x49\x4D\x45\x54':
# IMET with no build tag means the U8 archive should start at 0x600.
u8_data.seek(0x600)
self.u8_magic = u8_data.read(4)
if self.u8_magic != b'\x55\xAA\x38\x2D':
raise TypeError("This is not a valid U8 archive!")
# Parse the IMET header, then continue parsing the U8 archive.
u8_data.seek(0x0)
self.imet_header.load(u8_data.read(0x600))
else:
# This check will pass if the IMET comes after a build tag.
u8_data.seek(0x80)
self.u8_magic = u8_data.read(4)
if self.u8_magic == b'\x49\x4D\x45\x54':
# IMET with a build tag means the U8 archive should start at 0x640.
u8_data.seek(0x640)
self.u8_magic = u8_data.read(4)
if self.u8_magic != b'\x55\xAA\x38\x2D':
raise TypeError("This is not a valid U8 archive!")
# Parse the IMET header, then continue parsing the U8 archive.
u8_data.seek(0x40)
self.imet_header.load(u8_data.read(0x600))
else:
raise TypeError("This is not a valid U8 archive!")
# Offset of the root node, which will always be 0x20.
self.root_node_offset = int.from_bytes(u8_data.read(4))
# The size of the U8 header.
@@ -215,7 +258,7 @@ def extract_u8(u8_data, output_folder) -> None:
open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb").write(u8_archive.file_data_list[node])
# Handle an invalid node type.
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 1:
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was found!")
raise ValueError(f"A node with an invalid type ({str(u8_archive.u8_node_list[node].type)}) was found!")
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
@@ -257,14 +300,23 @@ def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
return u8_archive, node_count
def pack_u8(input_path) -> bytes:
def pack_u8(input_path, generate_imet=False, imet_titles:List[str]=None) -> bytes:
"""
Packs the provided file or folder into a new U8 archive, and returns the raw file data for it.
To generate an IMET header for this U8 archive, the archive must contain the required banner files "icon.bin",
"banner.bin", and "sound.bin", because the sizes of these files are stored in the header.
Parameters
----------
input_path
The path to the input file or folder.
generate_imet : bool, optional
Whether an IMET header should be generated for this U8 archive or not. IMET headers are only used for channel
banners (00000000.app), and required banner files must exist to generate this header. Defaults to False.
imet_titles : List[str], optional
A list of the channel title in different languages for the IMET header. If only one item is provided, that
item will be used for all entries in the header. Defaults to None, and is only used when generate_imet is True.
Returns
-------
@@ -283,8 +335,221 @@ def pack_u8(input_path) -> bytes:
# subdirectory and file. Discard node_count and name_offset since we don't care about them here, as they're
# really only necessary for the directory recursion.
u8_archive, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, parent_node=0)
if generate_imet:
print("gen imet")
return u8_archive.dump()
elif input_path.is_file():
raise ValueError("This does not appear to be a directory.")
else:
raise FileNotFoundError("Input directory: \"" + str(input_path) + "\" does not exist!")
raise FileNotFoundError(f"Input directory: \"{input_path}\" does not exist!")
class IMETHeader:
"""
An IMETHeader object that allows for parsing, editing, and generating an IMET header. These headers precede the
data of a channel banner (00000000.app), and are used to store metadata about the banner and verify its data.
An IMET header is always 1,536 (0x600) bytes long.
Attributes
----------
magic : str
Magic number for the header, should be "IMD5".
header_size : int
Length of the M
imet_version : int
Version of the IMET header. Normally always 3.
sizes : List[int]
The file sizes of icon.bin, banner.bin, and sound.bin.
flag1 : int
Unknown.
channel_names : List[str]
The name of the channel this header is for in Japanese, English, German, French, Spanish, Italian, Dutch,
Simplified Chinese, Traditional Chinese, and Korean, in that order.
md5_hash : bytes
MD5 sum of the entire header, with this field being all zeros during the hashing.
"""
def __init__(self):
self.magic: str = "" # Should always be "IMET"
self.header_size: int = 0 # Always 1536? I assumed this would mean something, but it's just the header length.
self.imet_version: int = 0 # Always 3?
self.sizes: List[int] = [] # Should only have 3 items
self.flag1: int = 0 # Unknown
self.channel_names: List[str] = [] # Should have 10 items
self.md5_hash: bytes = b''
class LocalizedTitles(_IntEnum):
TITLE_JAPANESE = 0
TITLE_ENGLISH = 1
TITLE_GERMAN = 2
TITLE_FRENCH = 3
TITLE_SPANISH = 4
TITLE_ITALIAN = 5
TITLE_DUTCH = 6
TITLE_CHINESE_SIMPLIFIED = 7
TITLE_CHINESE_TRADITIONAL = 8
TITLE_KOREAN = 9
def load(self, imet_data: bytes) -> None:
"""
Loads the raw data of an IMET header.
Parameters
----------
imet_data : bytes
The data for the IMET header to load.
"""
with io.BytesIO(imet_data) as data:
data.seek(0x40)
self.magic = str(data.read(4).decode())
self.header_size = int.from_bytes(data.read(4))
self.imet_version = int.from_bytes(data.read(4))
self.sizes = []
for _ in range(0, 3):
self.sizes.append(int.from_bytes(data.read(4)))
self.flag1 = int.from_bytes(data.read(4))
self.channel_names = []
for _ in range(0, 10):
# Read the translated channel name from the header, then drop all trailing null bytes. The encoding
# used here is UTF-16 Big Endian.
new_channel_name = data.read(84)
self.channel_names.append(str(new_channel_name.decode('utf-16-be')).replace('\x00', ''))
data.seek(data.tell() + 588)
self.md5_hash = binascii.hexlify(data.read(16))
def dump(self) -> bytes:
"""
Dump the IMETHeader back into raw bytes.
Returns
-------
bytes
The IMET header as bytes.
"""
imet_data = b''
# 64 bytes of padding.
imet_data += b'\x00' * 64
# "IMET" magic number.
imet_data += str.encode("IMET")
# IMET header size. TODO: check if this is actually always 1536
imet_data += int.to_bytes(1536, 4)
# IMET header version.
imet_data += int.to_bytes(self.imet_version, 4)
# Banner component sizes.
for size in self.sizes:
imet_data += int.to_bytes(size, 4)
# flag1.
imet_data += int.to_bytes(self.flag1, 4)
# Channel names.
for channel_name in self.channel_names:
new_channel_name = channel_name.encode('utf-16-be')
while len(new_channel_name) < 84:
new_channel_name += b'\x00'
imet_data += new_channel_name
# 588 (WHY??) bytes of padding.
imet_data += b'\x00' * 588
# MD5 hash. To calculate the real one, we need to write all zeros to it first, then hash the entire header with
# the zero hash. After that we'll replace this hash with the calculated one.
imet_data += b'\x00' * 16
imet_hash = hashlib.md5(imet_data)
imet_data = imet_data[:-16] + imet_hash.digest()
return imet_data
def create(self, sizes: List[int], channel_names: Tuple[int, str] | List[Tuple[int, str]]) -> None:
"""
Create a new IMET header, specifying the sizes of the banner components and one or more localized channel names.
Parameters
----------
sizes : List[int]
The size in bytes of icon.bin, banner.bin, and sound.bin, in that order.
channel_names : Tuple(int, str), List[Tuple[int, str]]
A pair or list of pairs of the target language and channel name for that language. Target languages are
defined in LocalizedTitles.
See Also
--------
libWiiPy.archive.u8.IMETHeader.LocalizedTitles
"""
# Begin by setting the constant values before we parse the input.
self.magic = "IMET"
self.header_size = 1536
self.imet_version = 3
self.flag1 = 0 # Still not really sure about this one.
# Validate the number of entries, then set the provided file sizes.
if len(sizes) != 3:
raise ValueError("You must supply 3 file sizes to generate an IMET header!")
self.sizes = sizes
# Now we can parse the channel names. This functions the same as setting them later, so just calling
# set_channel_names() is the most practical.
self.channel_names = ["" for _ in range(0, 10)]
self.set_channel_names(channel_names)
def get_channel_names(self, target_languages: int | List[int]) -> str | List[str]:
"""
Get one or more channel names from the IMET header based on the specified languages.
Parameters
----------
target_languages : int, List[int, str]
One or more target languages. Target languages are defined in LocalizedTitles.
Returns
-------
str, List[str]
The channel name for the specified language, or a list of channel names in the same order as the specified
languages.
See Also
--------
libWiiPy.archive.u8.IMETHeader.LocalizedTitles
"""
# Flatten single instance of LocalizedTitles being passed to a proper int.
if isinstance(target_languages, self.LocalizedTitles):
target_languages = int(target_languages)
# If only one channel name was requested.
if type(target_languages) == int:
if target_languages not in self.LocalizedTitles:
raise ValueError(f"The specified language is not valid!")
return self.channel_names[target_languages]
# If multiple channel names were requested.
else:
channel_names = []
for lang in target_languages:
if lang not in self.LocalizedTitles:
raise ValueError(f"The specified language at index {target_languages.index(lang)} is not valid!")
channel_names.append(self.channel_names[lang])
return channel_names
def set_channel_names(self, channel_names: Tuple[int, str] | List[Tuple[int, str]]) -> None:
"""
Specify one or more new channel names to set in the IMET header.
Parameters
----------
channel_names : Tuple(int, str), List[Tuple[int, str]]
A pair or list of pairs of the target language and channel name for that language. Target languages are
defined in LocalizedTitles.
See Also
--------
libWiiPy.archive.u8.IMETHeader.LocalizedTitles
"""
# If only one channel name was provided.
if type(channel_names) == tuple:
if channel_names[0] not in self.LocalizedTitles:
raise ValueError(f"The target language \"{channel_names[0]}\" is not valid!")
if len(channel_names[1]) > 42:
raise ValueError(f"The channel name \"{channel_names[1]}\" is too long! Channel names cannot exceed "
f"42 characters!")
self.channel_names[channel_names[0]] = channel_names[1]
# If a list of channel names was provided.
else:
for name in channel_names:
if name[0] not in self.LocalizedTitles:
raise ValueError(f"The target language \"{name[0]}\" for the name at index {channel_names.index(name)} "
f"is not valid!")
if len(name[1]) > 42:
raise ValueError(f"The channel name \"{name[1]}\" at index {channel_names.index(name)} is too long! "
f"Channel names cannot exceed 42 characters!")
self.channel_names[name[0]] = name[1]

View File

@@ -0,0 +1,4 @@
# "media/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .banner import *

View File

@@ -0,0 +1,31 @@
# "title/banner.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Opening.bnr for details about the Wii's banner format
from dataclasses import dataclass as _dataclass
@_dataclass
class IMD5Header:
"""
An IMD5Header object that contains the properties of an IMD5 header. These headers precede the data of banner.bin
and icon.bin inside the banner (00000000.app) of a channel, and are used to verify the data of those files.
An IMD5 header is always 32 bytes long.
Attributes
----------
magic : str
Magic number for the header, should be "IMD5".
file_size : int
The size of the file this header precedes.
zeros : int
8 bytes of zero padding.
md5_hash : bytes
The MD5 hash of the file this header precedes.
"""
magic: str # Should always be "IMD5"
file_size: int
zeros: int
md5_hash: bytes

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,12 @@
import os
import pathlib
import shutil
from .title import Title
from .content import SharedContentMap as _SharedContentMap
from dataclasses import dataclass as _dataclass
from typing import List
from ..title.ticket import Ticket
from ..title.title import Title
from ..title.tmd import TMD
from ..title.content import SharedContentMap as _SharedContentMap
from .sys import UidSys as _UidSys
@@ -73,7 +77,7 @@ class EmuNAND:
# Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/
ticket_dir = self.ticket_dir.joinpath(tid_upper)
ticket_dir.mkdir(exist_ok=True)
open(ticket_dir.joinpath(tid_lower + ".tik"), "wb").write(title.wad.get_ticket_data())
ticket_dir.joinpath(f"{tid_lower}.tik").write_bytes(title.ticket.dump())
# The TMD and normal contents are installed to /title/<tid_upper>/<tid_lower>/content/, with the tmd being named
# title.tmd and the contents being named <cid>.app.
@@ -85,11 +89,11 @@ class EmuNAND:
if content_dir.exists():
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
content_dir.mkdir(exist_ok=True)
open(content_dir.joinpath("title.tmd"), "wb").write(title.wad.get_tmd_data())
content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump())
for content_file in range(0, title.tmd.num_contents):
if title.tmd.content_records[content_file].content_type == 1:
content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower()
open(content_dir.joinpath(content_file_name + ".app"), "wb").write(
content_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash))
title_dir.joinpath("data").mkdir(exist_ok=True) # Empty directory used for save data for the title.
@@ -98,16 +102,16 @@ class EmuNAND:
content_map = _SharedContentMap()
existing_hashes = []
if content_map_path.exists():
content_map.load(open(content_map_path, "rb").read())
content_map.load(content_map_path.read_bytes())
for record in content_map.shared_records:
existing_hashes.append(record.content_hash)
for content_file in range(0, title.tmd.num_contents):
if title.tmd.content_records[content_file].content_type == 32769:
if title.tmd.content_records[content_file].content_hash not in existing_hashes:
content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash)
open(self.shared1_dir.joinpath(content_file_name + ".app"), "wb").write(
self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash))
open(self.shared1_dir.joinpath("content.map"), "wb").write(content_map.dump())
self.shared1_dir.joinpath("content.map").write_bytes(content_map.dump())
# The "footer" or meta file is installed as title.met in /meta/<tid_upper>/<tid_lower>/. Only write this if meta
# is not nothing.
@@ -117,7 +121,7 @@ class EmuNAND:
meta_dir.mkdir(exist_ok=True)
meta_dir = meta_dir.joinpath(tid_lower)
meta_dir.mkdir(exist_ok=True)
open(meta_dir.joinpath("title.met"), "wb").write(title.wad.get_meta_data())
meta_dir.joinpath("title.met").write_bytes(title.wad.get_meta_data())
# Ensure we have a uid.sys file created.
uid_sys_path = self.sys_dir.joinpath("uid.sys")
@@ -159,3 +163,96 @@ class EmuNAND:
# On the off chance this title has a meta entry, delete that too.
if self.meta_dir.joinpath(tid_upper).joinpath(tid_lower).joinpath("title.met").exists():
shutil.rmtree(self.meta_dir.joinpath(tid_upper).joinpath(tid_lower))
@_dataclass
class InstalledTitles:
"""
An InstalledTitles object that is used to track a title type and any titles that belong to that type that are
installed to an EmuNAND.
Attributes
----------
type : str
The type (Title ID high) of the installed titles.
titles : List[str]
The Title ID low of each installed title.
"""
type: str
titles: List[str]
def get_installed_titles(self) -> List[InstalledTitles]:
"""
Scans for installed titles and returns a list of InstalledTitles objects, which each contain a title type
(Title ID high) and a list of Title ID lows that are installed under it.
Returns
-------
List[InstalledTitles]
The titles installed to the EmuNAND.
"""
# Scan for TID highs present.
tid_highs = [d for d in self.title_dir.iterdir() if d.is_dir()]
# Iterate through each one, verify that every TID low directory contains a TMD, and then add it to the list.
installed_titles = []
for high in tid_highs:
tid_lows = [d for d in high.iterdir() if d.is_dir()]
valid_lows = []
for low in tid_lows:
if low.joinpath("content", "title.tmd").exists():
valid_lows.append(low.name)
installed_titles.append(self.InstalledTitles(high.name, valid_lows))
return installed_titles
def get_title_tmd(self, tid: str) -> TMD:
"""
Gets the TMD for a title installed to the EmuNAND, and returns it as a TMD objects. Returns an error if the
TMD for the specified Title ID does not exist.
Parameters
----------
tid : str
The Title ID of the Title to get the TMD for.
Returns
-------
TMD
The TMD for the Title.
"""
# Validate the TID, then build a path to the TMD file to verify that it exists.
if len(tid) != 16:
raise ValueError(f"Title ID \"{tid}\" is not a valid!")
tid_high = tid[:8].lower()
tid_low = tid[8:].lower()
tmd_path = self.title_dir.joinpath(tid_high, tid_low, "content", "title.tmd")
if not tmd_path.exists():
raise FileNotFoundError(f"Title with Title ID {tid} does not appear to be installed!")
tmd = TMD()
tmd.load(tmd_path.read_bytes())
return tmd
def get_title_ticket(self, tid: str) -> Ticket:
"""
Gets the Ticket for a title installed to the EmuNAND, and returns it as a Ticket object. Returns an error if
the Ticket for the specified Title ID does not exist.
Parameters
----------
tid : str
The Title ID of the Title to get the Ticket for.
Returns
-------
Ticket
The Ticket for the Title.
"""
# Validate the TID, then build a path to the Ticket files to verify that it exists.
if len(tid) != 16:
raise ValueError(f"Title ID \"{tid}\" is not a valid!")
tid_high = tid[:8].lower()
tid_low = tid[8:].lower()
ticket_path = self.ticket_dir.joinpath(tid_high, f"{tid_low}.tik")
if not ticket_path.exists():
raise FileNotFoundError(f"No Ticket exists for the title with Title ID {tid}!")
ticket = Ticket()
ticket.load(ticket_path.read_bytes())
return ticket

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

@@ -1,12 +1,11 @@
# "title/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .cert import *
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 *

312
src/libWiiPy/title/cert.py Normal file
View File

@@ -0,0 +1,312 @@
# "title/cert.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Certificate_chain for details about the Wii's certificate chain
import io
from enum import IntEnum as _IntEnum
from ..shared import _align_value, _pad_bytes
from .ticket import Ticket
from .tmd import TMD
from Crypto.Hash import SHA1
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
class CertificateType(_IntEnum):
RSA_4096 = 0x00010000
RSA_2048 = 0x00010001
ECC = 0x00010002
class CertificateSignatureLength(_IntEnum):
RSA_4096 = 0x200
RSA_2048 = 0x100
ECC = 0x3C
class CertificateKeyType(_IntEnum):
RSA_4096 = 0x00000000
RSA_2048 = 0x00000001
ECC = 0x00000002
class CertificateKeyLength(_IntEnum):
RSA_4096 = 0x200
RSA_2048 = 0x100
ECC = 0x3C
class Certificate:
"""
A Certificate object used to parse a certificate used for the Wii's content verification.
Attributes
----------
something : bool
I'm a placeholder attribute for later.
"""
def __init__(self):
self.type: CertificateType | None = None
self.signature: bytes = b''
self.issuer: str = ""
self.pub_key_type: CertificateKeyType | None = None
self.child_name: str = ""
self.pub_key_id: int = 0
self.pub_key_modulus: int = 0
self.pub_key_exponent: int = 0
def load(self, cert: bytes) -> None:
"""
Loads certificate data into the Certificate object, allowing you to parse the certificate.
Parameters
----------
cert: bytes
The data for the certificate to load.
"""
with io.BytesIO(cert) as cert_data:
# Read the first 4 bytes of the cert to get the certificate's type.
try:
self.type = CertificateType.from_bytes(cert_data.read(0x4))
except ValueError:
raise ValueError("Invalid Certificate Type!")
cert_length = CertificateSignatureLength[self.type.name]
self.signature = cert_data.read(cert_length.value)
cert_data.seek(0x40 + cert_length.value)
self.issuer = str(cert_data.read(0x40).replace(b'\x00', b'').decode())
try:
cert_data.seek(0x80 + cert_length.value)
self.pub_key_type = CertificateKeyType.from_bytes(cert_data.read(0x4))
except ValueError:
raise ValueError("Invalid Certificate Key type!")
cert_data.seek(0x84 + cert_length.value)
self.child_name = str(cert_data.read(0x40).replace(b'\x00', b'').decode())
cert_data.seek(0xC4 + cert_length.value)
self.pub_key_id = int.from_bytes(cert_data.read(0x4))
key_length = CertificateKeyLength[self.pub_key_type.name]
cert_data.seek(0xC8 + cert_length.value)
self.pub_key_modulus = int.from_bytes(cert_data.read(key_length.value))
if self.pub_key_type == CertificateKeyType.RSA_4096 or self.pub_key_type == CertificateKeyType.RSA_2048:
self.pub_key_exponent = int.from_bytes(cert_data.read(0x4))
def dump(self) -> bytes:
"""
Dump the certificate object back into bytes.
Returns
-------
bytes:
The certificate file as bytes.
"""
cert_data = b''
cert_data += int.to_bytes(self.type.value, 4)
cert_data += self.signature
cert_data = _pad_bytes(cert_data)
# Pad out the issuer name with null bytes.
issuer = self.issuer.encode()
while len(issuer) < 0x40:
issuer += b'\x00'
cert_data += issuer
cert_data += int.to_bytes(self.pub_key_type.value, 4)
# Pad out the child cert name with null bytes
child_name = self.child_name.encode()
while len(child_name) < 0x40:
child_name += b'\x00'
cert_data += child_name
cert_data += int.to_bytes(self.pub_key_id, 4)
cert_data += int.to_bytes(self.pub_key_modulus, CertificateKeyLength[self.pub_key_type.name])
if self.pub_key_type == CertificateKeyType.RSA_4096 or self.pub_key_type == CertificateKeyType.RSA_2048:
cert_data += int.to_bytes(self.pub_key_exponent, 4)
# Pad out the certificate data to a multiple of 64.
cert_data = _pad_bytes(cert_data)
return cert_data
class CertificateChain:
"""
A CertificateChain object used to parse the chain of certificates stored in a WAD that are used for the Wii's
content verification. The certificate chain is the format that the certificates are stored in as part of every WAD.
Attributes
----------
ca_cert: Certificate
The CA certificate from the chain.
tmd_cert: Certificate
The CP (TMD) certificate from the chain.
ticket_cert: Certificate
The XS (Ticket) certificate from the chain.
"""
def __init__(self):
self.ca_cert: Certificate = Certificate()
self.tmd_cert: Certificate = Certificate()
self.ticket_cert: Certificate = Certificate()
def load(self, cert_chain: bytes) -> None:
"""
Loads certificate chain data into the CertificateChain object, allowing you to parse the individual
certificates stored in the chain.
Parameters
----------
cert_chain: bytes
The data for the certificate chain to load.
"""
with (io.BytesIO(cert_chain) as cert_chain_data):
# Read the two fields that denote different length sections of the certificate, so that we know how long
# this certificate is in total.
offset = 0x0
for _ in range(3):
cert_chain_data.seek(offset)
cert_type = CertificateType.from_bytes(cert_chain_data.read(0x4))
cert_chain_data.seek(offset + 0x80 + CertificateSignatureLength[cert_type.name].value)
key_type = CertificateKeyType.from_bytes(cert_chain_data.read(0x4))
cert_size = _align_value(0xC8 + CertificateSignatureLength[cert_type.name].value +
CertificateKeyLength[key_type.name].value)
cert_chain_data.seek(offset + 0x0)
cert = Certificate()
cert.load(cert_chain_data.read(cert_size))
if cert.issuer == "Root":
self.ca_cert = cert
elif cert.issuer.find("Root-CA") != -1:
if cert.child_name.find("CP") != -1:
self.tmd_cert = cert
elif cert.child_name.find("XS") != -1:
self.ticket_cert = cert
else:
raise ValueError("Unknown certificate in chain!")
else:
raise ValueError("Unknown certificate in chain!")
offset += cert_size
def dump(self) -> bytes:
"""
Dumps the full certificate chain back into bytes. This chain will always be formatted with the CA cert first,
followed by the CP (TMD) cert, then finally the XS (Ticket) cert.
Returns
-------
bytes
The full certificate chain as bytes.
"""
cert_chain_data = b''
cert_chain_data += self.ca_cert.dump()
cert_chain_data += self.tmd_cert.dump()
cert_chain_data += self.ticket_cert.dump()
return cert_chain_data
def verify_ca_cert(ca_cert: Certificate) -> bool:
"""
Verify a Wii CA certificate using the root public key.
Parameters
----------
ca_cert: Certificate
The CA certificate to verify.
Returns
-------
bool
Whether the certificate is valid or not.
"""
root_key_modulus = (b'\xf8$lX\xba\xe7P\x03\x01\xfb\xb7\xc2\xeb\xe0\x01\x05q\xda\x92#x\xf0QN\xc0\x03\x1d\xd0\xd2\x1e'
b'\xd3\xd0~\xfc\x85 i\xb5\xde\x9b\xb9Q\xa8\xbc\x90\xa2D\x92m7\x92\x95\xae\x946\xaa\xa6\xa3\x02Q'
b'\x0c{\x1d\xed\xd5\xfb \x86\x9d\x7f0\x16\xf6\xbee\xd3\x83\xa1m\xb32\x1b\x955\x18\x90\xb1p\x02'
b'\x93~\xe1\x93\xf5~\x99\xa2GN\x9d8$\xc7\xae\xe3\x85A\xf5g\xe7Q\x8cz\x0e8\xe7\xeb\xafA\x19\x1b'
b'\xcf\xf1{B\xa6\xb4\xed\xe6\xce\x8d\xe71\x8f\x7fR\x04\xb3\x99\x0e"gE\xaf\xd4\x85\xb2D\x93\x00'
b'\x8b\x08\xc7\xf6\xb7\xe5k\x02\xb3\xe8\xfe\x0c\x9d\x85\x9c\xb8\xb6\x82#\xb8\xab\'\xee_e8\x07'
b'\x8b-\xb9\x1e*\x15>\x85\x81\x80r\xa2;m\xd92\x81\x05Oo\xb0\xf6\xf5\xad(>\xca\x0bz\xf3TU\xe0='
b'\xa7\xb6\x83&\xf3\xec\x83J\xf3\x14\x04\x8a\xc6\xdf \xd2\x85\x08g<\xabb\xa2\xc7\xbc\x13\x1aS>'
b'\x0bf\x80k\x1c0fK7#1\xbd\xc4\xb0\xca\xd8\xd1\x1e\xe7\xbb\xd9(UH\xaa\xec\x1ff\xe8!\xb3\xc8'
b'\xa0Gi\x00\xc5\xe6\x88\xe8\x0c\xce<a\xd6\x9c\xbb\xa17\xc6`Ozr\xdd\x8c{>=Q)\r\xaajY{\x08\x1f'
b'\x9d63\xa3Fz5a\t\xac\xa7\xdd}./\xb2\xc1\xae\xb8\xe2\x0fH\x92\xd8\xb9\xf8\xb4oN<\x11\xf4\xf4}'
b'\x8bu}\xfe\xfe\xa3\x89\x9c3Y\\^\xfd\xeb\xcb\xab\xe8A>:\x9a\x80<i5n\xb2\xb2\xad\\\xc4\xc8XE^'
b'\xf5\xf7\xb3\x06D\xb4|d\x06\x8c\xdf\x80\x9fv\x02Z-\xb4F\xe0=|\xf6/4\xe7\x02E{\x02\xa4\xcf]'
b'\x9d\xd5<\xa5:|\xa6)x\x8cg\xca\x08\xbf\xec\xcaC\xa9W\xad\x16\xc9N\x1c\xd8u\xca\x10}\xce~\x01'
b'\x18\xf0\xdfk\xfe\xe5\x1d\xdb\xd9\x91\xc2n`\xcdHX\xaaY,\x82\x00u\xf2\x9fRl\x91|o\xe5@>\xa7'
b'\xd4\xa5\x0c\xec;s\x84\xde\x88n\x82\xd2\xebMNB\xb5\xf2\xb1I\xa8\x1e\xa7\xceqD\xdc)\x94\xcf'
b'\xc4N\x1f\x91\xcb\xd4\x95')
root_key_exponent = 0x00010001
cert_hash = SHA1.new(ca_cert.dump()[576:])
public_key = RSA.construct((int.from_bytes(root_key_modulus), root_key_exponent))
try:
pkcs1_15.new(public_key).verify(cert_hash, ca_cert.signature)
return True
except ValueError:
return False
def verify_cert_sig(ca_cert: Certificate, target_cert: Certificate) -> bool:
"""
Verify a TMD or Ticket certificate using a CA certificate.
Parameters
----------
ca_cert: Certificate
The CA certificate to use for verification.
target_cert: Certificate
The target certificate to verify.
Returns
-------
bool
Whether the certificate's signature is valid or not.
"""
cert_hash = SHA1.new(target_cert.dump()[320:])
public_key = RSA.construct((ca_cert.pub_key_modulus, ca_cert.pub_key_exponent))
try:
pkcs1_15.new(public_key).verify(cert_hash, target_cert.signature)
return True
except ValueError:
return False
def verify_tmd_sig(tmd_cert: Certificate, tmd: TMD) -> bool:
"""
Verify the signature of a TMD file using a TMD certificate.
Parameters
----------
tmd_cert: Certificate
The TMD certificate to use for verification.
tmd: TMD
The TMD to verify.
Returns
-------
bool
Whether the TMD's signature is valid or not.
"""
tmd_hash = SHA1.new(tmd.dump()[320:])
public_key = RSA.construct((tmd_cert.pub_key_modulus, tmd_cert.pub_key_exponent))
try:
pkcs1_15.new(public_key).verify(tmd_hash, tmd.signature)
return True
except ValueError:
return False
def verify_ticket_sig(ticket_cert: Certificate, ticket: Ticket) -> bool:
"""
Verify the signature of a Ticket file using a Ticket certificate.
Parameters
----------
ticket_cert: Certificate
The Ticket certificate to use for verification.
ticket: Ticket
The Ticket to verify.
Returns
-------
bool
Whether the Ticket's signature is valid or not.
"""
ticket_hash = SHA1.new(ticket.dump()[320:])
public_key = RSA.construct((ticket_cert.pub_key_modulus, ticket_cert.pub_key_exponent))
try:
pkcs1_15.new(public_key).verify(ticket_hash, ticket.signature)
return True
except ValueError:
return False

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

@@ -1,8 +1,8 @@
# "title/crypto.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
import struct
import binascii
import struct
from .commonkeys import get_common_key
from Crypto.Cipher import AES as _AES
@@ -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

@@ -6,6 +6,7 @@
import requests
import hashlib
from typing import List
from urllib.parse import urlparse as _urlparse
from .title import Title
from .tmd import TMD
from .ticket import Ticket
@@ -13,7 +14,8 @@ from .ticket import Ticket
_nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"]
def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool = False) -> Title:
def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> Title:
"""
Download an entire title and all of its contents, then load the downloaded components into a Title object for
further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead
@@ -23,10 +25,13 @@ def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool
----------
title_id : str
The Title ID of the title to download.
title_version : int, option
title_version : int, optional
The version of the title to download. Defaults to latest if not set.
wiiu_endpoint : bool, option
wiiu_endpoint : bool, optional
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
@@ -36,17 +41,18 @@ def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool
# First, create the new title.
title = Title()
# Download and load the TMD, Ticket, and certs.
title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint))
title.load_ticket(download_ticket(title_id, wiiu_endpoint))
title.wad.set_cert_data(download_cert(wiiu_endpoint))
title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint, endpoint_override))
title.load_ticket(download_ticket(title_id, wiiu_endpoint, endpoint_override))
title.wad.set_cert_data(download_cert(wiiu_endpoint, endpoint_override))
# Download all contents
title.load_content_records()
title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint)
title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint, endpoint_override)
# Return the completed title.
return title
def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool = False) -> bytes:
def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> bytes:
"""
Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another
version if it was manually specified in the object.
@@ -59,6 +65,9 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
The version of the TMD to download. Defaults to latest if not set.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
@@ -67,10 +76,14 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
"""
# Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for
# when a specific version is requested.
if wiiu_endpoint is False:
tmd_url = _nus_endpoint[0] + title_id + "/tmd"
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
tmd_url = _nus_endpoint[1] + title_id + "/tmd"
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
tmd_url = endpoint_url + title_id + "/tmd"
# Add the version to the URL if one was specified.
if title_version is not None:
tmd_url += "." + str(title_version)
@@ -89,7 +102,7 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
return tmd
def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_override: str = None) -> bytes:
"""
Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for
a free title.
@@ -100,6 +113,9 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
The Title ID of the title to download the Ticket for.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
@@ -108,10 +124,14 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
"""
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
# title.
if wiiu_endpoint is False:
ticket_url = _nus_endpoint[0] + title_id + "/cetk"
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
ticket_url = _nus_endpoint[1] + title_id + "/cetk"
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
ticket_url = endpoint_url + title_id + "/cetk"
# Make the request.
ticket_request = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
if ticket_request.status_code != 200:
@@ -126,7 +146,7 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
return ticket
def download_cert(wiiu_endpoint: bool = False) -> bytes:
def download_cert(wiiu_endpoint: bool = False, endpoint_override: str = None) -> bytes:
"""
Downloads the signing certificate used by all WADs. This uses System Menu 4.3U as the source.
@@ -134,6 +154,9 @@ def download_cert(wiiu_endpoint: bool = False) -> bytes:
----------
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
@@ -141,12 +164,15 @@ def download_cert(wiiu_endpoint: bool = False) -> bytes:
The cert file.
"""
# Download the TMD and cetk for the System Menu 4.3U.
if wiiu_endpoint is False:
tmd_url = _nus_endpoint[0] + "0000000100000002/tmd.513"
cetk_url = _nus_endpoint[0] + "0000000100000002/cetk"
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
tmd_url = _nus_endpoint[1] + "0000000100000002/tmd.513"
cetk_url = _nus_endpoint[1] + "0000000100000002/cetk"
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
tmd_url = endpoint_url + "0000000100000002/tmd.513"
cetk_url = endpoint_url + "0000000100000002/cetk"
tmd = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
cetk = requests.get(url=cetk_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
# Assemble the certificate.
@@ -163,7 +189,8 @@ def download_cert(wiiu_endpoint: bool = False) -> bytes:
return cert
def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False) -> bytes:
def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> bytes:
"""
Downloads a specified content for the title specified in the object.
@@ -175,6 +202,9 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
The Content ID of the content you wish to download.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
@@ -185,10 +215,14 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
content_id_hex = hex(content_id)[2:]
if len(content_id_hex) < 2:
content_id_hex = "0" + content_id_hex
if wiiu_endpoint is False:
content_url = _nus_endpoint[0] + title_id + "/000000" + content_id_hex
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
content_url = _nus_endpoint[1] + title_id + "/000000" + content_id_hex
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
content_url = endpoint_url + title_id + "/000000" + content_id_hex
# Make the request.
content_request = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
if content_request.status_code != 200:
@@ -199,7 +233,8 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
return content_data
def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False) -> List[bytes]:
def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> List[bytes]:
"""
Downloads all the contents for the title specified in the object. This requires a TMD to already be available
so that the content records can be accessed.
@@ -212,6 +247,9 @@ def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False) -> L
The TMD that matches the title that the contents being downloaded are from.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
@@ -228,6 +266,29 @@ def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False) -> L
content_list = []
for content_id in content_ids:
# Call self.download_content() for each Content ID.
content = download_content(title_id, content_id, wiiu_endpoint)
content = download_content(title_id, content_id, wiiu_endpoint, endpoint_override)
content_list.append(content)
return content_list
def _validate_endpoint(endpoint: str) -> str:
"""
Validate the provided NUS endpoint URL and append the required path if necessary.
Parameters
----------
endpoint: str
The NUS endpoint URL to validate.
Returns
-------
str
The validated NUS endpoint with the proper path.
"""
# Find the root of the URL and then assemble the correct URL based on that.
new_url = _urlparse(endpoint)
if new_url.netloc == "":
endpoint_url = "http://" + new_url.path + "/ccs/download/"
else:
endpoint_url = "http://" + new_url.netloc + "/ccs/download/"
return endpoint_url

View File

@@ -23,12 +23,11 @@ class _TitleLimit:
Attributes
----------
limit_type : int
The type of play limit applied.
The type of play limit applied. 0 and 3 are none, 1 is a time limit, and 4 is a launch count limit.
maximum_usage : int
The maximum value for the type of play limit applied.
"""
# 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.
maximum_usage: int
@@ -40,6 +39,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 +58,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 +159,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 +324,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

@@ -58,9 +58,9 @@ class Title:
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 != str(self.ticket.title_id.decode()):
raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
"invalid.")
#if self.tmd.title_id != str(self.ticket.title_id.decode()):
# raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
# "invalid.")
def dump_wad(self) -> bytes:
"""
@@ -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

@@ -6,9 +6,10 @@
import io
import binascii
import hashlib
import math
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 +38,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 +46,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 +83,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 +108,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 +175,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 +188,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 +280,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 +293,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 +315,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,14 +391,71 @@ class TMD:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
"' contents but index was '" + str(record) + "'!")
class AccessFlags(IntEnum):
def get_content_size(self, absolute=False, dlc=False) -> int:
"""
Gets the installed size of the content listed in the TMD, in bytes. This does not include the size of hash tree
content, so the size of disc titles will not be calculated. The "absolute" option determines whether shared
content sizes should be included in the total size or not. This option defaults to False. The "dlc" option
determines whether DLC content sizes should be included in the total size or not. This option also defaults to
False.
Parameters
----------
absolute: bool, optional
Whether shared contents should be included in the total size or not. Defaults to False.
dlc: bool, optional
Whether DLC contents should be included in the total size or not. Defaults to False.
Returns
-------
int
The installed size of the content, in bytes.
"""
title_size = 0
for record in self.content_records:
if record.content_type == 0x8001:
if absolute:
title_size += record.content_size
elif record.content_type == 0x4001:
if dlc:
title_size += record.content_size
elif record.content_type != 3:
title_size += record.content_size
return title_size
def get_content_size_blocks(self, absolute=False, dlc=False) -> int:
"""
Gets the installed size of the content listed in the TMD, 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. The "dlc" option determines whether DLC content sizes should be included in the total
size or not. This option also 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.
dlc: bool, optional
Whether DLC contents should be included in the total size or not. Defaults to False.
Returns
-------
int
The installed size of the content, in blocks.
"""
title_size_bytes = self.get_content_size(absolute, dlc)
blocks = math.ceil(title_size_bytes / 131072)
return blocks
class AccessFlags(_IntEnum):
AHB = 0
DVD_VIDEO = 1
def get_access_right(self, flag: int) -> bool:
"""
Gets whether an access rights flag is enabled or not. This is done by checking the specified bit. Possible flags
and their corresponding bits are defined in the AccessFlags enum.
Gets whether the specified access rights flag is enabled or not. This is done by checking the specified bit.
Possible flags and their corresponding bits are defined in AccessFlags.
Parameters
----------
@@ -408,6 +466,10 @@ class TMD:
-------
bool
True if the flag is enabled, False otherwise.
See Also
--------
libWiiPy.title.tmd.TMD.AccessFlags
"""
return bool(self.access_rights & _bitmask(flag))

View File

@@ -1,5 +1,6 @@
# "types.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from dataclasses import dataclass