Merge pull request #10 from NinjaCheetah/decrypt_contents

Feat: Add ability to decrypt contents
This commit is contained in:
Campbell 2024-03-04 20:52:09 -05:00 committed by GitHub
commit 817fe1b499
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 267 additions and 120 deletions

98
src/libWiiPy/content.py Normal file
View File

@ -0,0 +1,98 @@
# "content.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import io
import sys
import hashlib
from typing import List
from .types import ContentRecord
from .crypto import decrypt_content
class ContentRegion:
"""Creates a ContentRegion object to parse the continuous content region of a WAD.
Attributes:
----------
content_region : bytes
A bytes object containing the content region of a WAD file.
content_records : list[ContentRecord]
A list of ContentRecord objects detailing all contents contained in the region.
"""
def __init__(self, content_region, content_records: List[ContentRecord]):
self.content_region = content_region
self.content_records = content_records
self.content_region_size: int # Size of the content region.
self.num_contents: int # Number of contents in the content region.
self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region.
with io.BytesIO(content_region) as content_region_data:
# Get the total size of the content region.
self.content_region_size = sys.getsizeof(content_region_data)
self.num_contents = len(self.content_records)
# Calculate the offsets of each content in the content region.
# Content is aligned to 16 bytes, however a new content won't start until the next multiple of 64 bytes.
# Because of this, we need to add bytes to the next 64 byte offset if the previous content wasn't that long.
for content in self.content_records[:-1]:
start_offset = content.content_size + self.content_start_offsets[-1]
if (content.content_size % 64) != 0:
start_offset += 64 - (content.content_size % 64)
self.content_start_offsets.append(start_offset)
def get_enc_content(self, index: int) -> bytes:
"""Gets an individual content from the content region based on the provided content record, in encrypted form.
Parameters
----------
index : int
The index of the content you want to get.
Returns
-------
bytes
The encrypted content listed in the content record.
"""
with io.BytesIO(self.content_region) as content_region_data:
# Seek to the start of the requested content based on the list of offsets.
content_region_data.seek(self.content_start_offsets[index])
# Calculate the number of bytes we need to read by adding bytes up the nearest multiple of 16 if needed.
bytes_to_read = self.content_records[index].content_size
if (bytes_to_read % 16) != 0:
bytes_to_read += 16 - (bytes_to_read % 16)
# Read the file based on the size of the content in the associated record.
content_enc = content_region_data.read(bytes_to_read)
return content_enc
def get_content(self, index: int, title_key: bytes) -> bytes:
"""Gets an individual content from the content region based on the provided content record, in decrypted form.
Parameters
----------
index : int
The index of the content you want to get.
title_key : bytes
The Title Key for the title the content is from.
Returns
-------
bytes
The decrypted content listed in the content record.
"""
# Load the encrypted content at the specified index and then decrypt it with the Title Key.
content_enc = self.get_enc_content(index)
content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index,
self.content_records[index].content_size)
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
# If it does not, then something has gone wrong in the decryption, and an error will be thrown.
content_dec_hash = hashlib.sha1(content_dec)
content_record_hash = str(self.content_records[index].content_hash.decode())
# Compare the hash and throw a ValueError if the hash doesn't match.
if content_dec_hash.hexdigest() != content_record_hash:
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may"
"have been used!.\n"
"Expected hash is: {}\n".format(content_record_hash) +
"Actual hash is: {}".format(content_dec_hash.hexdigest()))
return content_dec

View File

@ -1,13 +1,13 @@
# "crypto.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Ticket for details about the TMD format
import struct
from .commonkeys import get_common_key
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
def decrypt_title_key(title_key_enc, common_key_index, title_id):
def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes:
"""Gets the decrypted version of the encrypted Title Key provided.
Requires the index of the common key to use, and the Title ID of the title that the Title Key is for.
@ -19,7 +19,7 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id):
common_key_index : int
The index of the common key to be returned.
title_id : bytes
The title ID of the tite that the key is for.
The title ID of the title that the key is for.
Returns
-------
@ -35,3 +35,42 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id):
# Decrypt the Title Key using the AES object.
title_key = aes.decrypt(title_key_enc)
return title_key
def decrypt_content(content_enc, title_key, content_index, content_length) -> bytes:
"""Gets the decrypted version of the encrypted content.
Requires the index of the common key to use, and the Title ID of the title that the Title Key is for.
Parameters
----------
content_enc : bytes
The encrypted content.
title_key : bytes
The Title Key for the title the content is from.
content_index : int
The index in the TMD's content record of the content being decrypted.
content_length : int
The length in the TMD's content record of the content being decrypted.
Returns
-------
bytes
The decrypted content.
"""
# Generate the IV from the Content Index of the content to be decrypted.
content_index_bin = struct.pack('>H', content_index)
while len(content_index_bin) < 16:
content_index_bin += b'\x00'
# Align content to 64 bytes to ensure that all the data is being decrypted, and so it works with AES encryption.
if (len(content_enc) % 64) != 0:
print("needs padding to 64 bytes")
content_enc = content_enc + (b'\x00' * (64 - (len(content_enc) % 64)))
# Create a new AES object with the values provided, with the content's unique ID as the IV.
aes = AES.new(title_key, AES.MODE_CBC, content_index_bin)
# Decrypt the content using the AES object.
content_dec = aes.decrypt(content_enc)
# Trim additional bytes that may have been added so the content is the correct size.
while len(content_dec) > content_length:
content_dec = content_dec[:-1]
return content_dec

View File

@ -1,7 +1,7 @@
# "ticket.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Ticket for details about the TMD format
# See https://wiibrew.org/wiki/Ticket for details about the ticket format
import io
from .crypto import decrypt_title_key
@ -29,7 +29,13 @@ class TitleLimit:
class Ticket:
"""Creates a Ticket object to parse a Ticket file to retrieve the Title Key needed to decrypt it."""
"""Creates a Ticket object to parse a Ticket file to retrieve the Title Key needed to decrypt it.
Attributes:
----------
ticket : bytes
A bytes object containing the contents of a ticket file.
"""
def __init__(self, ticket):
self.ticket = ticket
@ -53,63 +59,63 @@ class Ticket:
self.title_limits_list: List[TitleLimit] = [] # List of play limits applied to the title.
# v1 ticket data
# TODO: Figure out v1 ticket stuff
with io.BytesIO(self.ticket) as ticketdata:
with io.BytesIO(self.ticket) as ticket_data:
# ====================================================================================
# Parses each of the keys contained in the Ticket.
# ====================================================================================
# Signature type
ticketdata.seek(0x0)
self.signature_type = ticketdata.read(4)
ticket_data.seek(0x0)
self.signature_type = ticket_data.read(4)
# Signature data
ticketdata.seek(0x04)
self.signature = ticketdata.read(256)
ticket_data.seek(0x04)
self.signature = ticket_data.read(256)
# Signature issuer
ticketdata.seek(0x140)
self.signature_issuer = str(ticketdata.read(64).decode())
ticket_data.seek(0x140)
self.signature_issuer = str(ticket_data.read(64).decode())
# ECDH data
ticketdata.seek(0x180)
self.ecdh_data = ticketdata.read(60)
ticket_data.seek(0x180)
self.ecdh_data = ticket_data.read(60)
# Ticket version
ticketdata.seek(0x1BC)
self.ticket_version = int.from_bytes(ticketdata.read(1))
ticket_data.seek(0x1BC)
self.ticket_version = int.from_bytes(ticket_data.read(1))
# Title Key (Encrypted by a common key)
ticketdata.seek(0x1BF)
self.title_key_enc = ticketdata.read(16)
ticket_data.seek(0x1BF)
self.title_key_enc = ticket_data.read(16)
# Ticket ID
ticketdata.seek(0x1D0)
self.ticket_id = ticketdata.read(8)
ticket_data.seek(0x1D0)
self.ticket_id = ticket_data.read(8)
# Console ID
ticketdata.seek(0x1D8)
self.console_id = int.from_bytes(ticketdata.read(4))
ticket_data.seek(0x1D8)
self.console_id = int.from_bytes(ticket_data.read(4))
# Title ID
ticketdata.seek(0x1DC)
self.title_id = ticketdata.read(8)
ticket_data.seek(0x1DC)
self.title_id = ticket_data.read(8)
# Title version
ticketdata.seek(0x1E6)
title_version_high = int.from_bytes(ticketdata.read(1)) * 256
ticketdata.seek(0x1E7)
title_version_low = int.from_bytes(ticketdata.read(1))
ticket_data.seek(0x1E6)
title_version_high = int.from_bytes(ticket_data.read(1)) * 256
ticket_data.seek(0x1E7)
title_version_low = int.from_bytes(ticket_data.read(1))
self.title_version = title_version_high + title_version_low
# Permitted titles mask
ticketdata.seek(0x1E8)
self.permitted_titles = ticketdata.read(4)
ticket_data.seek(0x1E8)
self.permitted_titles = ticket_data.read(4)
# Permit mask
ticketdata.seek(0x1EC)
self.permit_mask = ticketdata.read(4)
ticket_data.seek(0x1EC)
self.permit_mask = ticket_data.read(4)
# Whether title export with a PRNG key is allowed
ticketdata.seek(0x1F0)
self.title_export_allowed = int.from_bytes(ticketdata.read(1))
ticket_data.seek(0x1F0)
self.title_export_allowed = int.from_bytes(ticket_data.read(1))
# Common key index
ticketdata.seek(0x1F1)
self.common_key_index = int.from_bytes(ticketdata.read(1))
ticket_data.seek(0x1F1)
self.common_key_index = int.from_bytes(ticket_data.read(1))
# Content access permissions
ticketdata.seek(0x222)
self.content_access_permissions = ticketdata.read(64)
ticket_data.seek(0x222)
self.content_access_permissions = ticket_data.read(64)
# Content limits
ticketdata.seek(0x264)
ticket_data.seek(0x264)
for limit in range(0, 8):
limit_type = int.from_bytes(ticketdata.read(4))
limit_value = int.from_bytes(ticketdata.read(4))
limit_type = int.from_bytes(ticket_data.read(4))
limit_value = int.from_bytes(ticket_data.read(4))
self.title_limits_list.append(TitleLimit(limit_type, limit_value))
def get_signature(self):

View File

@ -6,33 +6,8 @@
import io
import binascii
import struct
from dataclasses import dataclass
from typing import List
@dataclass
class ContentRecord:
"""
Creates a content record object that contains the details of a content contained in a title.
Attributes:
----------
cid : int
ID of the content.
index : int
Index of the content in the list of contents.
content_type : int
The type of the content.
content_size : int
The size of the content.
content_hash
The SHA-1 hash of the decrypted content.
"""
cid: int # Content ID
index: int # Index in the list of contents
content_type: int # Normal: 0x0001, DLC: 0x4001, Shared: 0x8001
content_size: int
content_hash: bytes # SHA1 hash content
from .types import ContentRecord
class TMD:
@ -66,71 +41,71 @@ class TMD:
self.boot_index: int
self.content_records: List[ContentRecord] = []
# Load data from TMD file
with io.BytesIO(self.tmd) as tmddata:
with io.BytesIO(self.tmd) as tmd_data:
# ====================================================================================
# Parses each of the keys contained in the TMD.
# ====================================================================================
# Signing certificate issuer
tmddata.seek(0x140)
self.issuer = tmddata.read(64)
tmd_data.seek(0x140)
self.issuer = tmd_data.read(64)
# TMD version, seems to usually be 0, but I've seen references to other numbers
tmddata.seek(0x180)
self.version = int.from_bytes(tmddata.read(1))
tmd_data.seek(0x180)
self.version = int.from_bytes(tmd_data.read(1))
# TODO: label
tmddata.seek(0x181)
self.ca_crl_version = tmddata.read(1)
tmd_data.seek(0x181)
self.ca_crl_version = tmd_data.read(1)
# TODO: label
tmddata.seek(0x182)
self.signer_crl_version = tmddata.read(1)
tmd_data.seek(0x182)
self.signer_crl_version = tmd_data.read(1)
# If this is a vWii title or not
tmddata.seek(0x183)
self.vwii = int.from_bytes(tmddata.read(1))
tmd_data.seek(0x183)
self.vwii = int.from_bytes(tmd_data.read(1))
# TID of the IOS to use for the title, set to 0 if this title is the IOS, set to boot2 version if boot2
tmddata.seek(0x184)
ios_version_bin = tmddata.read(8)
tmd_data.seek(0x184)
ios_version_bin = tmd_data.read(8)
ios_version_hex = binascii.hexlify(ios_version_bin)
self.ios_tid = str(ios_version_hex.decode())
# Get IOS version based on TID
self.ios_version = int(self.ios_tid[-2:], 16)
# Title ID of the title
tmddata.seek(0x18C)
title_id_bin = tmddata.read(8)
tmd_data.seek(0x18C)
title_id_bin = tmd_data.read(8)
title_id_hex = binascii.hexlify(title_id_bin)
self.title_id = str(title_id_hex.decode())
# Type of content
tmddata.seek(0x194)
content_type_bin = tmddata.read(4)
tmd_data.seek(0x194)
content_type_bin = tmd_data.read(4)
content_type_hex = binascii.hexlify(content_type_bin)
self.content_type = str(content_type_hex.decode())
# Publisher of the title
tmddata.seek(0x198)
self.group_id = tmddata.read(2)
tmd_data.seek(0x198)
self.group_id = tmd_data.read(2)
# Region of the title, 0 = JAP, 1 = USA, 2 = EUR, 3 = NONE, 4 = KOR
tmddata.seek(0x19C)
region_hex = tmddata.read(2)
tmd_data.seek(0x19C)
region_hex = tmd_data.read(2)
self.region = int.from_bytes(region_hex)
# TODO: figure this one out
tmddata.seek(0x19E)
self.ratings = tmddata.read(16)
tmd_data.seek(0x19E)
self.ratings = tmd_data.read(16)
# Access rights of the title; DVD-video access and AHBPROT
tmddata.seek(0x1D8)
self.access_rights = tmddata.read(4)
tmd_data.seek(0x1D8)
self.access_rights = tmd_data.read(4)
# Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD
tmddata.seek(0x1DC)
title_version_high = int.from_bytes(tmddata.read(1)) * 256
tmddata.seek(0x1DD)
title_version_low = int.from_bytes(tmddata.read(1))
tmd_data.seek(0x1DC)
title_version_high = int.from_bytes(tmd_data.read(1)) * 256
tmd_data.seek(0x1DD)
title_version_low = int.from_bytes(tmd_data.read(1))
self.title_version = title_version_high + title_version_low
# The number of contents listed in the TMD
tmddata.seek(0x1DE)
self.num_contents = int.from_bytes(tmddata.read(2))
tmd_data.seek(0x1DE)
self.num_contents = int.from_bytes(tmd_data.read(2))
# Content index in content list that contains the boot file
tmddata.seek(0x1E0)
self.boot_index = tmddata.read(2)
tmd_data.seek(0x1E0)
self.boot_index = tmd_data.read(2)
# Get content records for the number of contents in num_contents.
for content in range(0, self.num_contents):
tmddata.seek(0x1E4 + (36 * content))
content_record_hdr = struct.unpack(">LHH4x4s20s", tmddata.read(36))
tmd_data.seek(0x1E4 + (36 * content))
content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36))
self.content_records.append(
ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]),
int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]),

29
src/libWiiPy/types.py Normal file
View File

@ -0,0 +1,29 @@
# "types.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from dataclasses import dataclass
@dataclass
class ContentRecord:
"""
Creates a content record object that contains the details of a content contained in a title.
Attributes:
----------
content_id : int
ID of the content.
index : int
Index of the content in the list of contents.
content_type : int
The type of the content.
content_size : int
The size of the content.
content_hash
The SHA-1 hash of the decrypted content.
"""
content_id: int # The unique ID of the content.
index: int # The index of this content in the content record.
content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared.
content_size: int # Size of the content when decrypted.
content_hash: bytes # SHA-1 hash of the content when decrypted.

View File

@ -37,36 +37,36 @@ class WAD:
self.wad_content_offset: int
self.wad_meta_offset: int
# Load header data from WAD stream
with io.BytesIO(self.wad) as waddata:
with io.BytesIO(self.wad) as wad_data:
# ====================================================================================
# Get the sizes of each data region contained within the WAD.
# ====================================================================================
# Header length, which will always be 64 bytes, as it is padded out if it is shorter.
self.wad_hdr_size = 64
# WAD type, denoting whether this WAD contains boot2 ("ib"), or anything else ("Is").
waddata.seek(0x04)
self.wad_type = str(waddata.read(2).decode())
wad_data.seek(0x04)
self.wad_type = str(wad_data.read(2).decode())
# WAD version, this is always 0.
waddata.seek(0x06)
self.wad_version = waddata.read(2)
wad_data.seek(0x06)
self.wad_version = wad_data.read(2)
# WAD cert size.
waddata.seek(0x08)
self.wad_cert_size = int(binascii.hexlify(waddata.read(4)), 16)
wad_data.seek(0x08)
self.wad_cert_size = int(binascii.hexlify(wad_data.read(4)), 16)
# WAD crl size.
waddata.seek(0x0c)
self.wad_crl_size = int(binascii.hexlify(waddata.read(4)), 16)
wad_data.seek(0x0c)
self.wad_crl_size = int(binascii.hexlify(wad_data.read(4)), 16)
# WAD ticket size.
waddata.seek(0x10)
self.wad_tik_size = int(binascii.hexlify(waddata.read(4)), 16)
wad_data.seek(0x10)
self.wad_tik_size = int(binascii.hexlify(wad_data.read(4)), 16)
# WAD TMD size.
waddata.seek(0x14)
self.wad_tmd_size = int(binascii.hexlify(waddata.read(4)), 16)
wad_data.seek(0x14)
self.wad_tmd_size = int(binascii.hexlify(wad_data.read(4)), 16)
# WAD content size.
waddata.seek(0x18)
self.wad_content_size = int(binascii.hexlify(waddata.read(4)), 16)
wad_data.seek(0x18)
self.wad_content_size = int(binascii.hexlify(wad_data.read(4)), 16)
# Publisher of the title contained in the WAD.
waddata.seek(0x1c)
self.wad_meta_size = int(binascii.hexlify(waddata.read(4)), 16)
wad_data.seek(0x1c)
self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16)
# ====================================================================================
# Calculate file offsets from sizes. Every section of the WAD is padded out to a multiple of 0x40.
# ====================================================================================