mirror of
https://github.com/ihaveamac/custom-install.git
synced 2025-12-06 06:41:45 +00:00
initial commit
This commit is contained in:
316
pyctr/types/tmd.py
Normal file
316
pyctr/types/tmd.py
Normal file
@@ -0,0 +1,316 @@
|
||||
# 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 hashlib import sha256
|
||||
from struct import pack
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from ..common import PyCTRError
|
||||
from ..util import readbe, readle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import BinaryIO, Iterable
|
||||
|
||||
__all__ = ['CHUNK_RECORD_SIZE', 'TitleMetadataError', 'InvalidSignatureTypeError', 'InvalidHashError',
|
||||
'ContentInfoRecord', 'ContentChunkRecord', 'ContentTypeFlags', 'TitleVersion', 'TitleMetadataReader']
|
||||
|
||||
CHUNK_RECORD_SIZE = 0x30
|
||||
|
||||
# sig-type: (sig-size, padding)
|
||||
signature_types = {
|
||||
# RSA_4096 SHA1 (unused on 3DS)
|
||||
0x00010000: (0x200, 0x3C),
|
||||
# RSA_2048 SHA1 (unused on 3DS)
|
||||
0x00010001: (0x100, 0x3C),
|
||||
# Elliptic Curve with SHA1 (unused on 3DS)
|
||||
0x00010002: (0x3C, 0x40),
|
||||
# RSA_4096 SHA256
|
||||
0x00010003: (0x200, 0x3C),
|
||||
# RSA_2048 SHA256
|
||||
0x00010004: (0x100, 0x3C),
|
||||
# ECDSA with SHA256
|
||||
0x00010005: (0x3C, 0x40),
|
||||
}
|
||||
|
||||
BLANK_SIG_PAIR = (0x00010004, b'\xFF' * signature_types[0x00010004][0])
|
||||
|
||||
|
||||
class TitleMetadataError(PyCTRError):
|
||||
"""Generic exception for TitleMetadata operations."""
|
||||
|
||||
|
||||
class InvalidTMDError(TitleMetadataError):
|
||||
"""Title Metadata is invalid."""
|
||||
|
||||
|
||||
class InvalidSignatureTypeError(InvalidTMDError):
|
||||
"""Invalid signature type was used."""
|
||||
|
||||
def __init__(self, sig_type):
|
||||
super().__init__(sig_type)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.args[0]:#010x}'
|
||||
|
||||
|
||||
class InvalidHashError(InvalidTMDError):
|
||||
"""Hash mismatch in the Title Metadata."""
|
||||
|
||||
|
||||
class InvalidInfoRecordError(InvalidHashError):
|
||||
"""Hash mismatch in the Content Info Records."""
|
||||
|
||||
def __init__(self, info_record):
|
||||
super().__init__(info_record)
|
||||
|
||||
def __str__(self):
|
||||
return f'Invalid info record: {self.args[0]}'
|
||||
|
||||
|
||||
class UnusualInfoRecordError(InvalidTMDError):
|
||||
"""Encountered Content Info Record that attempts to hash a Content Chunk Record that has already been hashed."""
|
||||
|
||||
def __init__(self, info_record, chunk_record):
|
||||
super().__init__(info_record, chunk_record)
|
||||
|
||||
def __str__(self):
|
||||
return f'Attempted to hash twice: {self.args[0]}, {self.args[1]}'
|
||||
|
||||
|
||||
class ContentTypeFlags(NamedTuple):
|
||||
encrypted: bool
|
||||
disc: bool
|
||||
cfm: bool
|
||||
optional: bool
|
||||
shared: bool
|
||||
|
||||
def __index__(self) -> int:
|
||||
return self.encrypted | (self.disc << 1) | (self.cfm << 2) | (self.optional << 14) | (self.shared << 15)
|
||||
|
||||
__int__ = __index__
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__int__().__format__(format_spec)
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, flags: int) -> 'ContentTypeFlags':
|
||||
# noinspection PyArgumentList
|
||||
return cls(bool(flags & 1), bool(flags & 2), bool(flags & 4), bool(flags & 0x4000), bool(flags & 0x8000))
|
||||
|
||||
|
||||
class ContentInfoRecord(NamedTuple):
|
||||
index_offset: int
|
||||
command_count: int
|
||||
hash: bytes
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return b''.join((self.index_offset.to_bytes(2, 'big'), self.command_count.to_bytes(2, 'big'), self.hash))
|
||||
|
||||
|
||||
class ContentChunkRecord(NamedTuple):
|
||||
id: str
|
||||
cindex: int
|
||||
type: ContentTypeFlags
|
||||
size: int
|
||||
hash: bytes
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return b''.join((bytes.fromhex(self.id), self.cindex.to_bytes(2, 'big'), int(self.type).to_bytes(2, 'big'),
|
||||
self.size.to_bytes(8, 'big'), self.hash))
|
||||
|
||||
|
||||
class TitleVersion(NamedTuple):
|
||||
major: int
|
||||
minor: int
|
||||
micro: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.major}.{self.minor}.{self.micro}'
|
||||
|
||||
def __index__(self) -> int:
|
||||
return (self.major << 10) | (self.minor << 4) | self.micro
|
||||
|
||||
__int__ = __index__
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__int__().__format__(format_spec)
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, ver: int) -> 'TitleVersion':
|
||||
# noinspection PyArgumentList
|
||||
return cls((ver >> 10) & 0x3F, (ver >> 4) & 0x3F, ver & 0xF)
|
||||
|
||||
|
||||
class TitleMetadataReader:
|
||||
"""
|
||||
Class for 3DS Title Metadata.
|
||||
|
||||
https://www.3dbrew.org/wiki/Title_metadata
|
||||
"""
|
||||
|
||||
__slots__ = ('title_id', 'save_size', 'srl_save_size', 'title_version', 'info_records',
|
||||
'chunk_records', 'content_count', 'signature', '_u_issuer', '_u_version', '_u_ca_crl_version',
|
||||
'_u_signer_crl_version', '_u_reserved1', '_u_system_version', '_u_title_type', '_u_group_id',
|
||||
'_u_reserved2', '_u_srl_flag', '_u_reserved3', '_u_access_rights', '_u_boot_count', '_u_padding')
|
||||
|
||||
# arguments prefixed with _u_ are values unused by the 3DS and/or are only kept around to generate the final tmd
|
||||
def __init__(self, *, title_id: str, save_size: int, srl_save_size: int, title_version: TitleVersion,
|
||||
info_records: 'Iterable[ContentInfoRecord]', chunk_records: 'Iterable[ContentChunkRecord]',
|
||||
signature=BLANK_SIG_PAIR, _u_issuer='Root-CA00000003-CP0000000b', _u_version=1, _u_ca_crl_version=0,
|
||||
_u_signer_crl_version=0, _u_reserved1=0, _u_system_version=b'\0' * 8, _u_title_type=b'\0\0\0@',
|
||||
_u_group_id=b'\0\0', _u_reserved2=b'\0\0\0\0', _u_srl_flag=0, _u_reserved3=b'\0' * 0x31,
|
||||
_u_access_rights=b'\0' * 4, _u_boot_count=b'\0\0', _u_padding=b'\0\0'):
|
||||
# TODO: add checks
|
||||
self.title_id = title_id.lower()
|
||||
self.save_size = save_size
|
||||
self.srl_save_size = srl_save_size
|
||||
self.title_version = title_version
|
||||
self.info_records = tuple(info_records)
|
||||
self.chunk_records = tuple(chunk_records)
|
||||
self.content_count = len(self.chunk_records)
|
||||
self.signature = signature # TODO: store this differently
|
||||
|
||||
# unused values
|
||||
self._u_issuer = _u_issuer
|
||||
self._u_version = _u_version
|
||||
self._u_ca_crl_version = _u_ca_crl_version
|
||||
self._u_signer_crl_version = _u_signer_crl_version
|
||||
self._u_reserved1 = _u_reserved1
|
||||
self._u_system_version = _u_system_version
|
||||
self._u_title_type = _u_title_type
|
||||
self._u_group_id = _u_group_id
|
||||
self._u_reserved2 = _u_reserved2
|
||||
self._u_srl_flag = _u_srl_flag
|
||||
self._u_reserved3 = _u_reserved3
|
||||
self._u_access_rights = _u_access_rights
|
||||
self._u_boot_count = _u_boot_count
|
||||
self._u_padding = _u_padding
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.title_id, self.save_size, self.srl_save_size, self.title_version,
|
||||
self.info_records, self.chunk_records))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f'<TitleMetadataReader title_id={self.title_id!r} title_version={self.title_version!r} '
|
||||
f'content_count={self.content_count!r}>')
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
sig_data = pack(f'>I {signature_types[self.signature[0]][0]}s {signature_types[self.signature[0]][1]}x',
|
||||
self.signature[0], self.signature[1])
|
||||
|
||||
info_records = b''.join(bytes(x) for x in self.info_records).ljust(0x900, b'\0')
|
||||
|
||||
header = pack('>64s b b b b 8s 8s 4s 2s I I 4s b 49s 4s H H 2s 2s 32s', self._u_issuer.encode('ascii'),
|
||||
self._u_version, self._u_ca_crl_version, self._u_signer_crl_version, self._u_reserved1,
|
||||
self._u_system_version, bytes.fromhex(self.title_id), self._u_title_type, self._u_group_id,
|
||||
self.save_size, self.srl_save_size, self._u_reserved2, self._u_srl_flag, self._u_reserved3,
|
||||
self._u_access_rights, self.title_version, self.content_count, self._u_boot_count,
|
||||
self._u_padding, sha256(info_records).digest())
|
||||
|
||||
chunk_records = b''.join(bytes(x) for x in self.chunk_records)
|
||||
|
||||
return sig_data + header + info_records + chunk_records
|
||||
|
||||
@classmethod
|
||||
def load(cls, fp: 'BinaryIO', verify_hashes: bool = True) -> 'TitleMetadataReader':
|
||||
"""Load a tmd from a file-like object."""
|
||||
sig_type = readbe(fp.read(4))
|
||||
try:
|
||||
sig_size, sig_padding = signature_types[sig_type]
|
||||
except KeyError:
|
||||
raise InvalidSignatureTypeError(sig_type)
|
||||
|
||||
signature = fp.read(sig_size)
|
||||
try:
|
||||
fp.seek(sig_padding, 1)
|
||||
except Exception:
|
||||
# most streams are probably seekable, but for some that aren't...
|
||||
fp.read(sig_padding)
|
||||
|
||||
header = fp.read(0xC4)
|
||||
if len(header) != 0xC4:
|
||||
raise InvalidTMDError('Header length is not 0xC4')
|
||||
|
||||
# only values that actually have a use are loaded here. (currently)
|
||||
# several fields in were left in from the Wii tmd and have no function on 3DS.
|
||||
title_id = header[0x4C:0x54].hex()
|
||||
save_size = readle(header[0x5A:0x5E])
|
||||
srl_save_size = readle(header[0x5E:0x62])
|
||||
title_version = TitleVersion.from_int(readbe(header[0x9C:0x9E]))
|
||||
content_count = readbe(header[0x9E:0xA0])
|
||||
|
||||
content_info_records_hash = header[0xA4:0xC4]
|
||||
|
||||
content_info_records_raw = fp.read(0x900)
|
||||
if len(content_info_records_raw) != 0x900:
|
||||
raise InvalidTMDError('Content info records length is not 0x900')
|
||||
|
||||
if verify_hashes:
|
||||
real_hash = sha256(content_info_records_raw)
|
||||
if content_info_records_hash != real_hash.digest():
|
||||
raise InvalidHashError('Content Info Records hash is invalid')
|
||||
|
||||
content_chunk_records_raw = fp.read(content_count * CHUNK_RECORD_SIZE)
|
||||
|
||||
chunk_records = []
|
||||
for cr_raw in (content_chunk_records_raw[i:i + CHUNK_RECORD_SIZE] for i in
|
||||
range(0, content_count * CHUNK_RECORD_SIZE, CHUNK_RECORD_SIZE)):
|
||||
chunk_records.append(ContentChunkRecord(id=cr_raw[0:4].hex(),
|
||||
cindex=readbe(cr_raw[4:6]),
|
||||
type=ContentTypeFlags.from_int(readbe(cr_raw[6:8])),
|
||||
size=readbe(cr_raw[8:16]),
|
||||
hash=cr_raw[16:48]))
|
||||
|
||||
info_records = []
|
||||
for ir_raw in (content_info_records_raw[i:i + 0x24] for i in range(0, 0x900, 0x24)):
|
||||
if ir_raw != b'\0' * 0x24:
|
||||
info_records.append(ContentInfoRecord(index_offset=readbe(ir_raw[0:2]),
|
||||
command_count=readbe(ir_raw[2:4]),
|
||||
hash=ir_raw[4:36]))
|
||||
|
||||
if verify_hashes:
|
||||
chunk_records_hashed = set()
|
||||
for ir in info_records:
|
||||
to_hash = []
|
||||
for cr in chunk_records[ir.index_offset:ir.index_offset + ir.command_count]:
|
||||
if cr in chunk_records_hashed:
|
||||
raise InvalidTMDError('attempting to hash chunk record twice')
|
||||
|
||||
chunk_records_hashed.add(cr)
|
||||
to_hash.append(cr)
|
||||
|
||||
hashed = sha256(b''.join(bytes(x) for x in to_hash))
|
||||
if hashed.digest() != ir.hash:
|
||||
raise InvalidInfoRecordError(ir)
|
||||
|
||||
# unused vales are loaded only for use when re-building the binary tmd
|
||||
u_issuer = header[0:0x40].decode('ascii').rstrip('\0')
|
||||
u_version = header[0x40]
|
||||
u_ca_crl_version = header[0x41]
|
||||
u_signer_crl_version = header[0x42]
|
||||
u_reserved1 = header[0x43]
|
||||
u_system_version = header[0x44:0x4C]
|
||||
u_title_type = header[0x54:0x58]
|
||||
u_group_id = header[0x58:0x5A]
|
||||
u_reserved2 = header[0x62:0x66]
|
||||
u_srl_flag = header[0x66] # is this one used for anything?
|
||||
u_reserved3 = header[0x67:0x98]
|
||||
u_access_rights = header[0x98:0x9C]
|
||||
u_boot_count = header[0xA0:0xA2]
|
||||
u_padding = header[0xA2:0xA4]
|
||||
|
||||
return cls(title_id=title_id, save_size=save_size, srl_save_size=srl_save_size, title_version=title_version,
|
||||
info_records=info_records, chunk_records=chunk_records, signature=(sig_type, signature),
|
||||
_u_issuer=u_issuer, _u_version=u_version, _u_ca_crl_version=u_ca_crl_version,
|
||||
_u_signer_crl_version=u_signer_crl_version, _u_reserved1=u_reserved1,
|
||||
_u_system_version=u_system_version, _u_title_type=u_title_type, _u_group_id=u_group_id,
|
||||
_u_reserved2=u_reserved2, _u_srl_flag=u_srl_flag, _u_reserved3=u_reserved3,
|
||||
_u_access_rights=u_access_rights, _u_boot_count=u_boot_count, _u_padding=u_padding)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, fn: str, *, verify_hashes: bool = True) -> 'TitleMetadataReader':
|
||||
with open(fn, 'rb') as f:
|
||||
return cls.load(f, verify_hashes=verify_hashes)
|
||||
Reference in New Issue
Block a user