Files
custom-install/pyctr/type/tmd.py
2019-11-12 00:29:11 -08:00

317 lines
12 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 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)