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
parser = ArgumentParser( description= 'Manually install a CIA to the SD card for a Nintendo 3DS system.') parser.add_argument('cia', help='CIA files', nargs='+') parser.add_argument('-m', '--movable', help='movable.sed file', required=True) parser.add_argument('-b', '--boot9', help='boot9 file') parser.add_argument('--sd', help='path to SD root') parser.add_argument('--skip-contents', help="don't add contents, only add title info entry", action='store_true') args = parser.parse_args() # set up the crypto engine to encrypt contents as they are written to the SD crypto = CryptoEngine(boot9=args.boot9) crypto.setup_sd_key_from_file(args.movable) # try to find the path to the SD card contents print('Finding path to install to...') sd_path = join(args.sd, 'Nintendo 3DS', crypto.id0.hex()) id1s = [] for d in scandir(sd_path): if d.is_dir() and len(d.name) == 32: try: id1_tmp = bytes.fromhex(d.name) except ValueError: continue else: id1s.append(d.name) # check the amount of id1 directories
def main(): print(len(sys.argv)) if len(sys.argv) < 9: print("""Arguments missing. Required arguments are: Nintendo 3DS folder --inputFolder folderPath Movable.sed path: --movable movablePath Output folder: --outputFolder folderPath Path to disa-extract.py: --disa disaPath """) return None inputFolder = None outputFolder = None movablePath = None disaPath = None i = 1 while i < len(sys.argv): print(i, sys.argv[i]) if sys.argv[i] == "--inputFolder" or sys.argv[i] == "-i": i += 1 inputFolder = sys.argv[i] elif sys.argv[i] == "--outputFolder" or sys.argv[i] == "-o": i += 1 outputFolder = sys.argv[i] elif sys.argv[i] == "--movable" or sys.argv[i] == "-m": i += 1 movablePath = sys.argv[i] elif sys.argv[i] == "--disa" or sys.argv[i] == "-e": i += 1 disaPath = sys.argv[i] releasesPath = sys.argv[i] else: print("Unable to parse {}".format(sys.argv[i])) i += 1 if inputFolder is None: print("Input folder is not present") if outputFolder is None: print("Output folder is not present") if movablePath is None: print("Movable path is not present") if disaPath is None: print("Path to disa-extract.py is not present") if inputFolder is None or outputFolder is None or movablePath is None or disaPath is None: return None print("All paths parsed") if not (pathlib.Path(inputFolder).is_dir()): print( "Input folder directory does not exist. Inputted path: {}".format( inputFolder)) if not (pathlib.Path(outputFolder).is_dir()): print( "Output folder directory does not exist. Inputted path: {}".format( outputFolder)) if not (os.path.isfile(movablePath)): print("movable does not exist. Inputted path: {}".format(movablePath)) if not (os.path.isfile(disaPath)): print("disa_extract.py does not exist. Inputted path: {}".format( disaPath)) print( "Creating crypto object, if this creates an error then boot9.bin is not found" ) crypto = CryptoEngine() print("Setting movable.sed for decryption") crypto.setup_sd_key_from_file(movablePath) print("Getting directories in Nintendo 3DS") #id0 = selectDirectory32char(inputFolder) id0 = crypto.id0 id0 = id0.hex() print("Getting directories in {}".format(id0)) id1 = selectDirectory32char(inputFolder + "\\" + id0) print("Starting dump and decrypt.") for titleHigh in os.listdir(inputFolder + "\\" + id0 + "\\" + id1 + "\\title\\"): print("Title High: {}".format(titleHigh)) for titleLow in os.listdir(inputFolder + "\\" + id0 + "\\" + id1 + "\\title\\" + titleHigh + "\\"): print("Title Low: {}".format(titleLow)) appFile = selectAppFile( crypto, inputFolder + "\\" + id0 + "\\" + id1 + "\\title\\" + titleHigh + "\\" + titleLow + "\\" + "content" + "\\") return None