mirror of
https://github.com/ihaveamac/custom-install.git
synced 2025-12-05 22:31:45 +00:00
238 lines
9.3 KiB
Python
238 lines
9.3 KiB
Python
# This file is a part of ninfs.
|
|
#
|
|
# Copyright (c) 2017-2019 Ian Burgwin
|
|
# This file is licensed under The MIT License (MIT).
|
|
# You can find the full license text in LICENSE.md in the root of this project.
|
|
|
|
from enum import IntEnum
|
|
from io import BytesIO
|
|
from threading import Lock
|
|
from typing import TYPE_CHECKING, NamedTuple
|
|
|
|
from ..common import PyCTRError, _ReaderOpenFileBase
|
|
from ..crypto import CryptoEngine, Keyslot
|
|
from ..types.ncch import NCCHReader
|
|
from ..types.tmd import TitleMetadataReader
|
|
from ..util import readle, roundup
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import BinaryIO, Dict, Optional, Union
|
|
|
|
ALIGN_SIZE = 64
|
|
|
|
|
|
class CIAError(PyCTRError):
|
|
"""Generic error for CIA operations."""
|
|
|
|
|
|
class InvalidCIAError(CIAError):
|
|
"""Invalid CIA header exception."""
|
|
|
|
|
|
class CIASection(IntEnum):
|
|
# these values as negative, as positive ones are used for contents
|
|
ArchiveHeader = -4
|
|
CertificateChain = -3
|
|
Ticket = -2
|
|
TitleMetadata = -1
|
|
Meta = -5
|
|
|
|
|
|
class CIARegion(NamedTuple):
|
|
section: 'Union[int, CIASection]'
|
|
offset: int
|
|
size: int
|
|
iv: bytes # only used for encrypted sections
|
|
|
|
|
|
class _CIASectionFile(_ReaderOpenFileBase):
|
|
"""Provides a raw CIA section as a file-like object."""
|
|
|
|
def __init__(self, reader: 'CIAReader', path: 'CIASection'):
|
|
super().__init__(reader, path)
|
|
self._info = reader.sections[path]
|
|
|
|
|
|
class CIAReader:
|
|
"""Class for the 3DS CIA container."""
|
|
|
|
closed = False
|
|
|
|
def __init__(self, fp: 'Union[str, BinaryIO]', *, case_insensitive: bool = True, crypto: CryptoEngine = None,
|
|
dev: bool = False, seeddb: str = None, load_contents: bool = True):
|
|
if isinstance(fp, str):
|
|
fp = open(fp, 'rb')
|
|
|
|
if crypto:
|
|
self._crypto = crypto
|
|
else:
|
|
self._crypto = CryptoEngine(dev=dev)
|
|
|
|
# store the starting offset so the CIA can be read from any point in the base file
|
|
self._start = fp.tell()
|
|
self._fp = fp
|
|
# store case-insensitivity for RomFSReader
|
|
self._case_insensitive = case_insensitive
|
|
# threading lock
|
|
self._lock = Lock()
|
|
|
|
header = fp.read(0x20)
|
|
|
|
archive_header_size = readle(header[0x0:0x4])
|
|
if archive_header_size != 0x2020:
|
|
raise InvalidCIAError('Archive Header Size is not 0x2020')
|
|
# in practice, the certificate chain is the same for all retail titles
|
|
cert_chain_size = readle(header[0x8:0xC])
|
|
# the ticket size usually never changes from 0x350
|
|
# there is one ticket (without an associated title) that is smaller though
|
|
ticket_size = readle(header[0xC:0x10])
|
|
# tmd contains info about the contents of the title
|
|
tmd_size = readle(header[0x10:0x14])
|
|
# meta contains info such as the SMDH and Title ID dependency list
|
|
meta_size = readle(header[0x14:0x18])
|
|
# content size is the total size of the contents
|
|
# I'm not sure what happens yet if one of the contents is not aligned to 0x40 bytes.
|
|
content_size = readle(header[0x18:0x20])
|
|
# the content index determines what contents are in the CIA
|
|
# this is not stored as int, so it's faster to parse(?)
|
|
content_index = fp.read(archive_header_size - 0x20)
|
|
|
|
active_contents = set()
|
|
for idx, b in enumerate(content_index):
|
|
offset = idx * 8
|
|
curr = b
|
|
for x in range(7, -1, -1):
|
|
if curr & 1:
|
|
active_contents.add(x + offset)
|
|
curr >>= 1
|
|
|
|
# the header only stores sizes; offsets need to be calculated.
|
|
# the sections are aligned to 64(0x40) bytes. for example, if something is 0x78,
|
|
# it will take up 0x80, with the remaining 0x8 being padding.
|
|
cert_chain_offset = roundup(archive_header_size, ALIGN_SIZE)
|
|
ticket_offset = cert_chain_offset + roundup(cert_chain_size, ALIGN_SIZE)
|
|
tmd_offset = ticket_offset + roundup(ticket_size, ALIGN_SIZE)
|
|
content_offset = tmd_offset + roundup(tmd_size, ALIGN_SIZE)
|
|
meta_offset = content_offset + roundup(content_size, ALIGN_SIZE)
|
|
|
|
# lazy method to get the total size
|
|
self.total_size = meta_offset + meta_size
|
|
|
|
# this contains the location of each section, as well as the IV of encrypted ones
|
|
self.sections: Dict[Union[int, CIASection], CIARegion] = {}
|
|
|
|
def add_region(section: 'Union[int, CIASection]', offset: int, size: int, iv: 'Optional[bytes]'):
|
|
region = CIARegion(section=section, offset=offset, size=size, iv=iv)
|
|
self.sections[section] = region
|
|
|
|
# add each part of the header
|
|
add_region(CIASection.ArchiveHeader, 0, archive_header_size, None)
|
|
add_region(CIASection.CertificateChain, cert_chain_offset, cert_chain_size, None)
|
|
add_region(CIASection.Ticket, ticket_offset, ticket_size, None)
|
|
add_region(CIASection.TitleMetadata, tmd_offset, tmd_size, None)
|
|
if meta_size:
|
|
add_region(CIASection.Meta, meta_offset, meta_size, None)
|
|
|
|
# this will load the titlekey to decrypt the contents
|
|
self._fp.seek(self._start + ticket_offset)
|
|
ticket = self._fp.read(ticket_size)
|
|
self._crypto.load_from_ticket(ticket)
|
|
|
|
# the tmd describes the contents: ID, index, size, and hash
|
|
self._fp.seek(self._start + tmd_offset)
|
|
tmd_data = self._fp.read(tmd_size)
|
|
self.tmd = TitleMetadataReader.load(BytesIO(tmd_data))
|
|
|
|
active_contents_tmd = set()
|
|
self.content_info = []
|
|
|
|
# this does a first check to make sure there are no missing contents that are marked active in content_index
|
|
for record in self.tmd.chunk_records:
|
|
if record.cindex in active_contents:
|
|
active_contents_tmd.add(record.cindex)
|
|
self.content_info.append(record)
|
|
|
|
# if the result of this is not an empty set, it means there are contents enabled in content_index
|
|
# that are not in the tmd, which is bad
|
|
if active_contents ^ active_contents_tmd:
|
|
raise InvalidCIAError('Missing active contents in the TMD')
|
|
|
|
self.contents = {}
|
|
|
|
# this goes through the contents and figures out their regions, then creates an NCCHReader
|
|
curr_offset = content_offset
|
|
for record in self.content_info:
|
|
iv = None
|
|
if record.type.encrypted:
|
|
iv = record.cindex.to_bytes(2, 'big') + (b'\0' * 14)
|
|
add_region(record.cindex, curr_offset, record.size, iv)
|
|
if load_contents:
|
|
# check if the content is a Nintendo DS ROM (SRL) first
|
|
is_srl = record.cindex == 0 and self.tmd.title_id[3:5] == '48'
|
|
if not is_srl:
|
|
content_fp = self.open_raw_section(record.cindex)
|
|
self.contents[record.cindex] = NCCHReader(content_fp, case_insensitive=case_insensitive,
|
|
dev=dev, seeddb=seeddb)
|
|
|
|
curr_offset += record.size
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
try:
|
|
self._fp.close()
|
|
except AttributeError:
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.close()
|
|
|
|
__del__ = close
|
|
|
|
def __repr__(self):
|
|
info = [('title_id', self.tmd.title_id)]
|
|
try:
|
|
info.append(('title_name', repr(self.contents[0].exefs.icon.get_app_title().short_desc)))
|
|
except KeyError:
|
|
info.append(('title_name', 'unknown'))
|
|
info.append(('content_count', len(self.contents)))
|
|
info_final = " ".join(x + ": " + str(y) for x, y in info)
|
|
return f'<{type(self).__name__} {info_final}>'
|
|
|
|
def open_raw_section(self, section: 'CIASection'):
|
|
"""Open a raw CIA section for reading."""
|
|
return _CIASectionFile(self, section)
|
|
|
|
def get_data(self, region: 'CIARegion', offset: int, size: int) -> bytes:
|
|
if offset + size > region.size:
|
|
# prevent reading past the region
|
|
size = region.size - offset
|
|
|
|
with self._lock:
|
|
if region.iv:
|
|
real_size = size
|
|
# if encrypted, the block needs to be decrypted first
|
|
# CBC requires a full block (0x10 in this case). and the previous
|
|
# block is used as the IV. so that's quite a bit to read if the
|
|
# application requires just a few bytes.
|
|
# thanks Stary2001 for help with random-access crypto
|
|
before = offset % 16
|
|
if size % 16 != 0:
|
|
size = size + 16 - size % 16
|
|
if offset - before == 0:
|
|
iv = region.iv
|
|
else:
|
|
self._fp.seek(self._start + region.offset + offset - before - 0x10)
|
|
iv = self._fp.read(0x10)
|
|
# read to block size
|
|
self._fp.seek(self._start + region.offset + offset - before)
|
|
# adding x10 to the size fixes some kind of decryption bug I think. this needs more testing.
|
|
return self._crypto.create_cbc_cipher(Keyslot.DecryptedTitlekey,
|
|
iv).decrypt(self._fp.read(size + 0x10))[before:real_size + before]
|
|
else:
|
|
# no encryption
|
|
self._fp.seek(self._start + region.offset + offset)
|
|
return self._fp.read(size)
|