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) print(f'Writing {enc_path}...') with cia.open_raw_section(c.cindex) as s, open(out_path, 'wb') as o: copy_with_progress(s, o, c.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) print(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) print(f'Generating {enc_path}') highest_index = 0 content_ids = {} for record in cia.content_info:
class CustomInstall: cia: CIAReader def __init__(self, boot9, seeddb, movable, cias, sd, skip_contents=False): self.event = Events() self.log_lines = [] # Stores all info messages for user to view self.crypto = CryptoEngine(boot9=boot9) self.crypto.setup_sd_key_from_file(movable) self.seeddb = seeddb self.cias = cias self.sd = sd self.skip_contents = skip_contents self.movable = movable def copy_with_progress(self, src: BinaryIO, dst: BinaryIO, size: int, path: str): left = size cipher = self.crypto.create_ctr_cipher(Keyslot.SD, self.crypto.sd_path_to_iv(path)) while left > 0: to_read = min(READ_SIZE, left) data = cipher.encrypt(src.read(READ_SIZE)) dst.write(data) left -= to_read total_read = size - left self.event.update_percentage((total_read / size) * 100, total_read / 1048576, size / 1048576) 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.') def get_sd_path(self): sd_path = join(self.sd, 'Nintendo 3DS', self.crypto.id0.hex()) id1s = [] for d in scandir(sd_path): if d.is_dir() and len(d.name) == 32: try: # check if the name can be converted to hex # I'm not sure what the 3DS does if there is a folder that is not a 32-char hex string. bytes.fromhex(d.name) except ValueError: continue else: id1s.append(d.name) return [sd_path, id1s] def log(self, message, mtype=0, errorname=None, end='\n'): """Logs an Message with a type. Format is similar to python errors There are 3 types of errors, indexed accordingly type 0 = Message type 1 = Warning type 2 = Error optionally, errorname can be a custom name as a string to identify errors easily """ if errorname: errorname += ": " else: # No errorname provided errorname = "" types = [ "", # Type 0 "Warning: ", # Type 1 "Error: " # Type 2 ] # Example: "Warning: UninformativeError: An error occured, try again."" msg_with_type = types[mtype] + errorname + str(message) self.log_lines.append(msg_with_type) self.event.on_log_msg(msg_with_type, end=end) return msg_with_type
def path_to_iv(self, path): return CryptoEngine.sd_path_to_iv(path[self.root_len + 33:])
print('No id1 directory could be found in', id0, file=sys.stderr) sys.exit(2) id1 = id1_list[0] dbs_folder = id1 / 'dbs' dbs_folder.mkdir(exist_ok=True) title_db_path = dbs_folder / 'title.db' import_db_path = dbs_folder / 'import.db' with gzip.open('title.db.gz') as gzfh: db_data = gzfh.read() with title_db_path.open('wb') as fh: with crypto.create_ctr_io(Keyslot.SD, fh, CryptoEngine.sd_path_to_iv('/dbs/title.db')) as cfh: cmac = crypto.create_cmac_object(Keyslot.CMACSDNAND) cmac_data = [b'CTR-9DB0', 0x2.to_bytes(4, 'little'), db_data[0x100:0x200]] cmac.update(sha256(b''.join(cmac_data)).digest()) cfh.write(cmac.digest()) cfh.write(db_data[0x10:]) with import_db_path.open('wb') as fh: with crypto.create_ctr_io(Keyslot.SD, fh, CryptoEngine.sd_path_to_iv('/dbs/import.db')) as cfh: cmac = crypto.create_cmac_object(Keyslot.CMACSDNAND) cmac_data = [b'CTR-9DB0', 0x3.to_bytes(4, 'little'), db_data[0x100:0x200]] cmac.update(sha256(b''.join(cmac_data)).digest()) cfh.write(cmac.digest()) cfh.write(db_data[0x10:])
sys.exit(1) elif len(id1_list) < 1: print('No id1 directory could be found in', id0, file=sys.stderr) sys.exit(2) id1 = id1_list[0] title_dir = id1 / 'title' for tmd_path in title_dir.rglob('*.tmd'): tmd_id = int(tmd_path.name[0:8], 16) tmd_path_for_cid = '/' + '/'.join(tmd_path.parts[len(id1.parts):]) with tmd_path.open('rb') as tmd_fh: with crypto.create_ctr_io( Keyslot.SD, tmd_fh, crypto.sd_path_to_iv(tmd_path_for_cid)) as tmd_cfh: try: tmd = TitleMetadataReader.load(tmd_cfh) except Exception as e: print(f'Failed to parse tmd at {tmd_path}') traceback.print_exc() print('Parsing', tmd.title_id) if tmd.title_id.startswith('0004008c'): # DLC puts contents into different folders, the first content always goes in the first one content0_path = tmd_path.parent / '00000000' / ( tmd.chunk_records[0].id + '.app') has_manual = False else: content0_path = tmd_path.parent / (tmd.chunk_records[0].id + '.app')