Beispiel #1
0
    def init(self, path):
        decrypted_filename = '/decrypted.' + ('cxi' if self.reader.flags.executable else 'cfa')

        self.files[decrypted_filename] = {'size': self.reader.content_size, 'offset': 0, 'enctype': 'fulldec'}
        self.files['/ncch.bin'] = {'size': 0x200, 'offset': 0, 'enctype': 'none'}

        if self.reader.check_for_extheader():
            self.files['/extheader.bin'] = {'size': 0x800, 'offset': 0x200, 'enctype': 'normal',
                                            'keyslot': 0x2C, 'iv': (self.reader.partition_id << 64 | (0x01 << 56))}

        plain_region = self.reader.plain_region
        if plain_region.offset:
            self.files['/plain.bin'] = {'size': plain_region.size, 'offset': plain_region.offset, 'enctype': 'none'}

        logo_region = self.reader.logo_region
        if logo_region.offset:
            self.files['/logo.bin'] = {'size': logo_region.size, 'offset': logo_region.offset, 'enctype': 'none'}

        exefs_region = self.reader.exefs_region
        if exefs_region.offset:
            self.files['/exefs.bin'] = {'size': exefs_region.size, 'offset': exefs_region.offset, 'enctype': 'exefs',
                                        'keyslot': 0x2C, 'keyslot_extra': self.reader.extra_keyslot,
                                        'iv': (self.reader.partition_id << 64 | (0x02 << 56)),
                                        'keyslot_normal_range': [(0, 0x200)]}

            # noinspection PyBroadException
            try:
                # get code compression bit
                decompress = False
                if self.decompress_code and self.reader.check_for_extheader():
                    exh_flag = self.read('/extheader.bin', 1, 0xD, 0)
                    decompress = exh_flag[0] & 1
                exefs_vfp = _c.VirtualFileWrapper(self, '/exefs.bin', exefs_region.size)
                exefs_fuse = ExeFSMount(exefs_vfp, self._g_stat, decompress_code=decompress, strict=True)
                exefs_fuse.init(path)
                self.exefs_fuse = exefs_fuse
            except Exception as e:
                print(f'Failed to mount ExeFS: {type(e).__name__}: {e}')
            else:
                if not self.reader.flags.no_crypto:
                    for n, ent in self.exefs_fuse.reader.entries.items():
                        if n in {'icon', 'banner'}:
                            self.files['/exefs.bin']['keyslot_normal_range'].append(
                                (ent.offset + 0x200, ent.offset + 0x200 + roundup(ent.size, 0x200)))

        if not self.reader.flags.no_romfs:
            romfs_region = self.reader.romfs_region
            if romfs_region.offset:
                self.files['/romfs.bin'] = {'size': romfs_region.size, 'offset': romfs_region.offset,
                                            'enctype': 'normal', 'keyslot': self.reader.extra_keyslot,
                                            'iv': (self.reader.partition_id << 64 | (0x03 << 56))}

                # noinspection PyBroadException
                try:
                    romfs_vfp = _c.VirtualFileWrapper(self, '/romfs.bin', romfs_region.size)
                    romfs_fuse = RomFSMount(romfs_vfp, self._g_stat)
                    romfs_fuse.init(path)
                    self.romfs_fuse = romfs_fuse
                except Exception as e:
                    print(f'Failed to mount RomFS: {type(e).__name__}: {e}')
Beispiel #2
0
        print('Installing...')

    # this includes the sizes for all the files that would be in the title, plus each directory
    #   except the separate directories for DLC contents, which don't count towards the size.
    # five "1"s are here for the tidlow and content directories, the cmd file and its directory, and the tmd file
    sizes = [1] * 5

    if cia.tmd.save_size:
        # one for the data directory, one for the 00000001.sav file
        sizes.extend((1, cia.tmd.save_size))

    for record in cia.content_info:
        sizes.append(record.size)

    # this calculates the size to put in the Title Info Entry
    title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)

    # checks if this is dlc, which has some differences
    is_dlc = tid_parts[0] == '0004008c'

    # this checks if it has a manual (index 1) and is not DLC
    has_manual = (not is_dlc) and (1 in cia.contents)

    # this gets the extdata id from the extheader, stored in the storage info area
    try:
        with cia.contents[0].open_raw_section(NCCHSection.ExtendedHeader) as e:
            e.seek(0x200 + 0x30)
            extdata_id = e.read(8)
    except KeyError:
        # not an executable title
        extdata_id = b'\0' * 8
Beispiel #3
0
    def __init__(self,
                 nand_fp: BinaryIO,
                 g_stat: dict,
                 dev: bool = False,
                 readonly: bool = False,
                 otp: bytes = None,
                 cid: AnyStr = None,
                 boot9: str = None):
        self.crypto = CryptoEngine(boot9=boot9, dev=dev)

        self.g_stat = g_stat

        nand_fp.seek(0x100)  # screw the signature
        ncsd_header = nand_fp.read(0x100)
        if ncsd_header[0:4] != b'NCSD':
            exit(
                'NCSD magic not found, is this a real Nintendo 3DS NAND image?'
            )
        media_id = ncsd_header[0x8:0x10]
        if media_id != b'\0' * 8:
            exit(
                'Media ID not all-zero, is this a real Nintendo 3DS NAND image?'
            )

        # check for essential.exefs
        nand_fp.seek(0x200)
        try:
            exefs = ExeFSReader(nand_fp)
        except InvalidExeFSError:
            exefs = None

        otp_data = None
        if otp:
            try:
                with open(otp, 'rb') as f:
                    otp_data = f.read(0x200)
            except Exception:
                print(f'Failed to open and read given OTP ({otp}).\n')
                print_exc()
                exit(1)

        else:
            if exefs is None:
                exit(
                    'OTP not found, provide with --otp or embed essentials backup with GodMode9'
                )
            else:
                try:
                    with exefs.open('otp') as otp:
                        otp_data = otp.read(0x200)
                except ExeFSFileNotFoundError:
                    exit(
                        '"otp" not found in essentials backup, update with GodMode9 or provide with --otp'
                    )

        self.crypto.setup_keys_from_otp(otp_data)

        def generate_ctr():
            print(
                'Attempting to generate Counter for CTR/TWL areas. If errors occur, provide the CID manually.'
            )

            # -------------------------------------------------- #
            # attempt to generate CTR Counter
            nand_fp.seek(0xB9301D0)
            # these blocks are assumed to be entirely 00, so no need to xor anything
            ctrn_block_0x1d = nand_fp.read(0x10)
            ctrn_block_0x1e = nand_fp.read(0x10)
            for ks in (Keyslot.CTRNANDOld, Keyslot.CTRNANDNew):
                ctr_counter_offs = self.crypto.create_ecb_cipher(ks).decrypt(
                    ctrn_block_0x1d)
                ctr_counter = int.from_bytes(ctr_counter_offs,
                                             'big') - 0xB9301D

                # try the counter
                out = self.crypto.create_ctr_cipher(
                    ks, ctr_counter + 0xB9301E).decrypt(ctrn_block_0x1e)
                if out == b'\0' * 16:
                    print('Counter for CTR area automatically generated.')
                    self.ctr = ctr_counter
                    break
            else:
                print(
                    'Counter could not be generated for CTR area. Related virtual files will not appear.'
                )
                self.ctr = None

            # -------------------------------------------------- #
            # attempt to generate TWL Counter
            nand_fp.seek(0x1C0)
            twln_block_0x1c = readbe(nand_fp.read(0x10))
            twl_blk_xored = twln_block_0x1c ^ 0x18000601A03F97000000A97D04000004
            twl_counter_offs = self.crypto.create_ecb_cipher(0x03).decrypt(
                twl_blk_xored.to_bytes(0x10, 'little'))
            twl_counter = int.from_bytes(twl_counter_offs, 'big') - 0x1C

            # try the counter
            twln_block_0x1d = nand_fp.read(0x10)
            out = self.crypto.create_ctr_cipher(0x03, twl_counter +
                                                0x1D).decrypt(twln_block_0x1d)
            if out == b'\x8e@\x06\x01\xa0\xc3\x8d\x80\x04\x00\xb3\x05\x01\x00\x00\x00':
                print('Counter for TWL area automatically generated.')
                self.ctr_twl = twl_counter
            else:
                print(
                    'Counter could not be generated for TWL area. Related virtual files will not appear.'
                )
                self.ctr_twl = None

        cid_data = None
        if cid:
            try:
                with open(cid, 'rb') as f:
                    cid_data = f.read(0x200)
            except Exception:
                print(f'Failed to open and read given CID ({cid}).')
                print(
                    'If you want to attempt Counter generation, do not provide a CID path.\n'
                )
                print_exc()
                exit(1)

        else:
            if exefs is None:
                generate_ctr()
            else:
                try:
                    with exefs.open('nand_cid') as cid:
                        cid_data = cid.read(0x10)
                except ExeFSFileNotFoundError:
                    print(
                        '"nand_cid" not found in essentials backup, update with GodMode9 or provide with --cid'
                    )
                    generate_ctr()

        if cid_data:
            self.ctr = readbe(sha256(cid_data).digest()[0:16])
            self.ctr_twl = readle(sha1(cid_data).digest()[0:16])

        if not (self.ctr or self.ctr_twl):
            exit("Couldn't generate Counter for both CTR/TWL. "
                 "Make sure the OTP is correct, or provide the CID manually.")

        nand_fp.seek(0, 2)
        raw_nand_size = nand_fp.tell()

        self.real_nand_size = nand_size[readle(ncsd_header[4:8])]

        self.files = {
            '/nand_hdr.bin': {
                'size': 0x200,
                'offset': 0,
                'keyslot': 0xFF,
                'type': 'raw'
            },
            '/nand.bin': {
                'size': raw_nand_size,
                'offset': 0,
                'keyslot': 0xFF,
                'type': 'raw'
            },
            '/nand_minsize.bin': {
                'size': self.real_nand_size,
                'offset': 0,
                'keyslot': 0xFF,
                'type': 'raw'
            }
        }

        nand_fp.seek(0x12C00)
        keysect_enc = nand_fp.read(0x200)
        if len(set(keysect_enc)) != 1:
            keysect_dec = self.crypto.create_ecb_cipher(0x11).decrypt(
                keysect_enc)
            # i'm cheating here by putting the decrypted version in memory and
            #   not reading from the image every time. but it's not AES-CTR so
            #   f**k that.
            self.files['/sector0x96.bin'] = {
                'size': 0x200,
                'offset': 0x12C00,
                'keyslot': 0x11,
                'type': 'keysect',
                'content': keysect_dec
            }

        ncsd_part_fstype = ncsd_header[0x10:0x18]
        ncsd_part_crypttype = ncsd_header[0x18:0x20]
        ncsd_part_raw = ncsd_header[0x20:0x60]
        ncsd_partitions = [[
            readle(ncsd_part_raw[i:i + 4]) * 0x200,
            readle(ncsd_part_raw[i + 4:i + 8]) * 0x200
        ] for i in range(0, 0x40, 0x8)]

        # including padding for crypto
        if self.ctr_twl:
            twl_mbr = self.crypto.create_ctr_cipher(
                Keyslot.TWLNAND,
                self.ctr_twl + 0x1B).decrypt(ncsd_header[0xB0:0x100])[0xE:0x50]
            if twl_mbr[0x40:0x42] == b'\x55\xaa':
                twl_partitions = [[
                    readle(twl_mbr[i + 8:i + 12]) * 0x200,
                    readle(twl_mbr[i + 12:i + 16]) * 0x200
                ] for i in range(0, 0x40, 0x10)]
            else:
                twl_partitions = None

            self.files['/twlmbr.bin'] = {
                'size': 0x42,
                'offset': 0x1BE,
                'keyslot': Keyslot.TWLNAND,
                'type': 'twlmbr',
                'content': twl_mbr
            }
        else:
            twl_partitions = None

        # then actually parse the partitions to create files
        firm_idx = 0
        for idx, part in enumerate(ncsd_partitions):
            if ncsd_part_fstype[idx] == 0:
                continue
            print(
                f'ncsd idx:{idx} fstype:{ncsd_part_fstype[idx]} crypttype:{ncsd_part_crypttype[idx]} '
                f'offset:{part[0]:08x} size:{part[1]:08x} ',
                end='')
            if idx == 0:
                if self.ctr_twl:
                    self.files['/twl_full.img'] = {
                        'size': part[1],
                        'offset': part[0],
                        'keyslot': Keyslot.TWLNAND,
                        'type': 'enc'
                    }
                    print('/twl_full.img')
                    if twl_partitions:
                        twl_part_fstype = 0
                        for t_idx, t_part in enumerate(twl_partitions):
                            if t_part[0] != 0:
                                print(
                                    f'twl  idx:{t_idx}                      '
                                    f'offset:{t_part[0]:08x} size:{t_part[1]:08x} ',
                                    end='')
                                if twl_part_fstype == 0:
                                    self.files['/twln.img'] = {
                                        'size': t_part[1],
                                        'offset': t_part[0],
                                        'keyslot': Keyslot.TWLNAND,
                                        'type': 'enc'
                                    }
                                    print('/twln.img')
                                    twl_part_fstype += 1
                                elif twl_part_fstype == 1:
                                    self.files['/twlp.img'] = {
                                        'size': t_part[1],
                                        'offset': t_part[0],
                                        'keyslot': Keyslot.TWLNAND,
                                        'type': 'enc'
                                    }
                                    print('/twlp.img')
                                    twl_part_fstype += 1
                                else:
                                    self.files[
                                        f'/twl_unk{twl_part_fstype}.img'] = {
                                            'size': t_part[1],
                                            'offset': t_part[0],
                                            'keyslot': Keyslot.TWLNAND,
                                            'type': 'enc'
                                        }
                                    print(f'/twl_unk{twl_part_fstype}.img')
                                    twl_part_fstype += 1
                else:
                    print('<ctr_twl not set>')

            elif self.ctr:
                if ncsd_part_fstype[idx] == 3:
                    # boot9 hardcoded this keyslot, i'll do this properly later
                    self.files[f'/firm{firm_idx}.bin'] = {
                        'size': part[1],
                        'offset': part[0],
                        'keyslot': Keyslot.FIRM,
                        'type': 'enc'
                    }
                    print(f'/firm{firm_idx}.bin')
                    firm_idx += 1

                elif ncsd_part_fstype[
                        idx] == 1 and ncsd_part_crypttype[idx] >= 2:
                    ctrnand_keyslot = Keyslot.CTRNANDOld if ncsd_part_crypttype[
                        idx] == 2 else Keyslot.CTRNANDNew
                    self.files['/ctrnand_full.img'] = {
                        'size': part[1],
                        'offset': part[0],
                        'keyslot': ctrnand_keyslot,
                        'type': 'enc'
                    }
                    print('/ctrnand_full.img')
                    nand_fp.seek(part[0])
                    iv = self.ctr + (part[0] >> 4)
                    ctr_mbr = self.crypto.create_ctr_cipher(
                        ctrnand_keyslot,
                        iv).decrypt(nand_fp.read(0x200))[0x1BE:0x200]
                    if ctr_mbr[0x40:0x42] == b'\x55\xaa':
                        ctr_partitions = [[
                            readle(ctr_mbr[i + 8:i + 12]) * 0x200,
                            readle(ctr_mbr[i + 12:i + 16]) * 0x200
                        ] for i in range(0, 0x40, 0x10)]
                        ctr_part_fstype = 0
                        for c_idx, c_part in enumerate(ctr_partitions):
                            if c_part[0] != 0:
                                print(
                                    f'ctr  idx:{c_idx}                      offset:{part[0] + c_part[0]:08x} '
                                    f'size:{c_part[1]:08x} ',
                                    end='')
                                if ctr_part_fstype == 0:
                                    self.files['/ctrnand_fat.img'] = {
                                        'size': c_part[1],
                                        'offset': part[0] + c_part[0],
                                        'keyslot': ctrnand_keyslot,
                                        'type': 'enc'
                                    }
                                    print('/ctrnand_fat.img')
                                    ctr_part_fstype += 1
                                else:
                                    self.files[
                                        f'/ctr_unk{ctr_part_fstype}.img'] = {
                                            'size': c_part[1],
                                            'offset': part[0] + c_part[0],
                                            'keyslot': ctrnand_keyslot,
                                            'type': 'enc'
                                        }
                                    print(f'/ctr_unk{ctr_part_fstype}.img')
                                    ctr_part_fstype += 1

                elif ncsd_part_fstype[idx] == 4:
                    self.files['/agbsave.bin'] = {
                        'size': part[1],
                        'offset': part[0],
                        'keyslot': Keyslot.AGB,
                        'type': 'enc'
                    }
                    print('/agbsave.bin')

            else:
                print('<ctr not set>')

        self.readonly = readonly

        # GM9 bonus drive
        if raw_nand_size != self.real_nand_size:
            nand_fp.seek(self.real_nand_size)
            bonus_drive_header = nand_fp.read(0x200)
            if bonus_drive_header[0x1FE:0x200] == b'\x55\xAA':
                self.files['/bonus.img'] = {
                    'size': raw_nand_size - self.real_nand_size,
                    'offset': self.real_nand_size,
                    'keyslot': 0xFF,
                    'type': 'raw'
                }

        self.f = nand_fp

        if exefs is not None:
            exefs_size = sum(
                roundup(x.size, 0x200) for x in exefs.entries.values()) + 0x200
            self.files['/essential.exefs'] = {
                'size': exefs_size,
                'offset': 0x200,
                'keyslot': 0xFF,
                'type': 'raw'
            }
            try:
                self.exefs_fuse = ExeFSMount(exefs, g_stat=g_stat)
                self.exefs_fuse.init('/')
                self._essentials_mounted = True
            except Exception as e:
                print(
                    f'Failed to mount essential.exefs: {type(e).__name__}: {e}'
                )
Beispiel #4
0
    def start(self):
        crypto = self.crypto
        # TODO: Move a lot of these into their own methods
        self.log("Finding path to install to...")
        [sd_path, id1s] = self.get_sd_path()
        try:
            if len(id1s) > 1:
                raise SDPathError(
                    f'There are multiple id1 directories for id0 {crypto.id0.hex()}, '
                    f'please remove extra directories')
            elif len(id1s) == 0:
                raise SDPathError(
                    f'Could not find a suitable id1 directory for id0 {crypto.id0.hex()}'
                )
        except SDPathError:
            self.log("")

        cifinish_path = join(self.sd, 'cifinish.bin')
        sd_path = join(sd_path, id1s[0])
        title_info_entries = {}
        cifinish_data = load_cifinish(cifinish_path)

        # Now loop through all provided cia files

        for c in self.cias:
            self.log('Reading ' + c)

            cia = CIAReader(c, seeddb=self.seeddb)
            self.cia = cia

            tid_parts = (cia.tmd.title_id[0:8], cia.tmd.title_id[8:16])

            try:
                self.log(
                    f'Installing {cia.contents[0].exefs.icon.get_app_title().short_desc}...'
                )
            except:
                self.log('Installing...')

            sizes = [1] * 5

            if cia.tmd.save_size:
                # one for the data directory, one for the 00000001.sav file
                sizes.extend((1, cia.tmd.save_size))

            for record in cia.content_info:
                sizes.append(record.size)

            # this calculates the size to put in the Title Info Entry
            title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)

            # checks if this is dlc, which has some differences
            is_dlc = tid_parts[0] == '0004008c'

            # this checks if it has a manual (index 1) and is not DLC
            has_manual = (not is_dlc) and (1 in cia.contents)

            # this gets the extdata id from the extheader, stored in the storage info area
            try:
                with cia.contents[0].open_raw_section(
                        NCCHSection.ExtendedHeader) as e:
                    e.seek(0x200 + 0x30)
                    extdata_id = e.read(8)
            except KeyError:
                # not an executable title
                extdata_id = b'\0' * 8

            # cmd content id, starts with 1 for non-dlc contents
            cmd_id = len(cia.content_info) if is_dlc else 1
            cmd_filename = f'{cmd_id:08x}.cmd'

            # get the title root where all the contents will be
            title_root = join(sd_path, 'title', *tid_parts)
            content_root = join(title_root, 'content')
            # generate the path used for the IV
            title_root_cmd = f'/title/{"/".join(tid_parts)}'
            content_root_cmd = title_root_cmd + '/content'

            makedirs(join(content_root, 'cmd'), exist_ok=True)
            if cia.tmd.save_size:
                makedirs(join(title_root, 'data'), exist_ok=True)
            if is_dlc:
                # create the separate directories for every 256 contents
                for x in range(((len(cia.content_info) - 1) // 256) + 1):
                    makedirs(join(content_root, f'{x:08x}'))

            # maybe this will be changed in the future
            tmd_id = 0

            tmd_filename = f'{tmd_id:08x}.tmd'

            if not self.skip_contents:
                # write the tmd
                enc_path = content_root_cmd + '/' + tmd_filename
                self.log(f'Writing {enc_path}...')
                with cia.open_raw_section(CIASection.TitleMetadata) as s:
                    with open(join(content_root, tmd_filename), 'wb') as o:
                        self.copy_with_progress(
                            s, o, cia.sections[CIASection.TitleMetadata].size,
                            enc_path)

                # write each content
                for co in cia.content_info:
                    content_filename = co.id + '.app'
                    if is_dlc:
                        dir_index = format((co.cindex // 256), '08x')
                        enc_path = content_root_cmd + f'/{dir_index}/{content_filename}'
                        out_path = join(content_root, dir_index,
                                        content_filename)
                    else:
                        enc_path = content_root_cmd + '/' + content_filename
                        out_path = join(content_root, content_filename)
                    self.log(f'Writing {enc_path}...')
                    with cia.open_raw_section(co.cindex) as s, open(
                            out_path, 'wb') as o:
                        self.copy_with_progress(s, o, co.size, enc_path)

                # generate a blank save
                if cia.tmd.save_size:
                    enc_path = title_root_cmd + '/data/00000001.sav'
                    out_path = join(title_root, 'data', '00000001.sav')
                    cipher = crypto.create_ctr_cipher(
                        Keyslot.SD, crypto.sd_path_to_iv(enc_path))
                    # in a new save, the first 0x20 are all 00s. the rest can be random
                    data = cipher.encrypt(b'\0' * 0x20)
                    self.log(f'Generating blank save at {enc_path}...')
                    with open(out_path, 'wb') as o:
                        o.write(data)
                        o.write(b'\0' * (cia.tmd.save_size - 0x20))

                # generate and write cmd
                enc_path = content_root_cmd + '/cmd/' + cmd_filename
                out_path = join(content_root, 'cmd', cmd_filename)
                self.log(f'Generating {enc_path}')
                highest_index = 0
                content_ids = {}

                for record in cia.content_info:
                    highest_index = record.cindex
                    with cia.open_raw_section(record.cindex) as s:
                        s.seek(0x100)
                        cmac_data = s.read(0x100)

                    id_bytes = bytes.fromhex(record.id)[::-1]
                    cmac_data += record.cindex.to_bytes(4, 'little') + id_bytes

                    cmac_ncch = crypto.create_cmac_object(Keyslot.CMACSDNAND)
                    cmac_ncch.update(sha256(cmac_data).digest())
                    content_ids[record.cindex] = (id_bytes, cmac_ncch.digest())

                # add content IDs up to the last one
                ids_by_index = [CMD_MISSING] * (highest_index + 1)
                installed_ids = []
                cmacs = []
                for x in range(len(ids_by_index)):
                    try:
                        info = content_ids[x]
                    except KeyError:
                        # "MISSING CONTENT!"
                        # The 3DS does generate a cmac for missing contents, but I don't know how it works.
                        # It doesn't matter anyway, the title seems to be fully functional.
                        cmacs.append(
                            bytes.fromhex('4D495353494E4720434F4E54454E5421'))
                    else:
                        ids_by_index[x] = info[0]
                        cmacs.append(info[1])
                        installed_ids.append(info[0])
                installed_ids.sort(key=lambda x: int.from_bytes(x, 'little'))

                final = (cmd_id.to_bytes(4, 'little') +
                         len(ids_by_index).to_bytes(4, 'little') +
                         len(installed_ids).to_bytes(4, 'little') +
                         (1).to_bytes(4, 'little'))
                cmac_cmd_header = crypto.create_cmac_object(Keyslot.CMACSDNAND)
                cmac_cmd_header.update(final)
                final += cmac_cmd_header.digest()

                final += b''.join(ids_by_index)
                final += b''.join(installed_ids)
                final += b''.join(cmacs)

                cipher = crypto.create_ctr_cipher(
                    Keyslot.SD, crypto.sd_path_to_iv(enc_path))
                self.log(f'Writing {enc_path}')
                with open(out_path, 'wb') as o:
                    o.write(cipher.encrypt(final))

            # this starts building the title info entry
            title_info_entry_data = [
                # title size
                title_size.to_bytes(8, 'little'),
                # title type, seems to usually be 0x40
                0x40.to_bytes(4, 'little'),
                # title version
                int(cia.tmd.title_version).to_bytes(2, 'little'),
                # ncch version
                cia.contents[0].version.to_bytes(2, 'little'),
                # flags_0, only checking if there is a manual
                (1 if has_manual else 0).to_bytes(4, 'little'),
                # tmd content id, always starting with 0
                (0).to_bytes(4, 'little'),
                # cmd content id
                cmd_id.to_bytes(4, 'little'),
                # flags_1, only checking save data
                (1 if cia.tmd.save_size else 0).to_bytes(4, 'little'),
                # extdataid low
                extdata_id[0:4],
                # reserved
                b'\0' * 4,
                # flags_2, only using a common value
                0x100000000.to_bytes(8, 'little'),
                # product code
                cia.contents[0].product_code.encode('ascii').ljust(
                    0x10, b'\0'),
                # reserved
                b'\0' * 0x10,
                # unknown
                randint(0, 0xFFFFFFFF).to_bytes(4, 'little'),
                # reserved
                b'\0' * 0x2c
            ]

            title_info_entries[cia.tmd.title_id] = b''.join(
                title_info_entry_data)

            cifinish_data[int(cia.tmd.title_id, 16)] = {
                'seed': (cia.contents[0].seed
                         if cia.contents[0].flags.uses_seed else None)
            }

        save_cifinish(cifinish_path, cifinish_data)

        with TemporaryDirectory(suffix='-custom-install') as tempdir:
            # set up the common arguments for the two times we call save3ds_fuse
            save3ds_fuse_common_args = [
                join(script_dir, 'bin', platform,
                     'save3ds_fuse'), '-b', crypto.b9_path, '-m', self.movable,
                '--sd', self.sd, '--db', 'sdtitle', tempdir
            ]

            # extract the title database to add our own entry to
            self.log('Extracting Title Database...')
            subprocess.run(save3ds_fuse_common_args + ['-x'])

            for title_id, entry in title_info_entries.items():
                # write the title info entry to the temp directory
                with open(join(tempdir, title_id), 'wb') as o:
                    o.write(entry)

            # import the directory, now including our title
            self.log('Importing into Title Database...')
            subprocess.run(save3ds_fuse_common_args + ['-i'])

        self.log(
            'FINAL STEP:\nRun custom-install-finalize through homebrew launcher.'
        )
        self.log('This will install a ticket and seed if required.')