Skip to content

Instantly share code, notes, and snippets.

@Metalcape
Last active September 27, 2025 02:16
Show Gist options
  • Select an option

  • Save Metalcape/b9d487811d28d0df310bb0bf3d37a38b to your computer and use it in GitHub Desktop.

Select an option

Save Metalcape/b9d487811d28d0df310bb0bf3d37a38b to your computer and use it in GitHub Desktop.
DSi-enhanced cryptography & signatures

DSi-enhanced games make use of an extended header together with other data structures which implement additional integrity and obfuscation measures over those already present in NDS games. These measures make it currently impossible to produce a ROM image that will run on unmodded retail hardware, however due to the existence of leaked debug keys, it is possible to resign retail ROMs to make them bootable on dev hardware such as the IS-TWL-DEBUGGER.

Header

A very good description of the TWL header format can be found on GBATEK. It is expanded from the 512 bytes of the DS to 4KiB, and it contains many additional entries, but I will be mainly referring to the following:

  • The flags field (0x1BF)
  • Digest sector entries (0x1E0 - 0x208)]
  • Modcrypt entries (0x220 - 0x230)
  • HMAC array (0x300 - 0x3B4)
  • RSA signature (0xF80 - 0x1000)

Additional binaries

The regular DS has two CPUs, ARM9 and ARM7, and ROMs contain an executable for each of these two processors. To maintain compatibility with NDS consoles, DSi-enhanced games contain ARM9 and ARM7 binaries that do not make use of the additional features of the DSi. Instead, the TWL-exclusive code is located in two other binaries, called ARM9i and ARM7i. These binaries are stored in the high part of the ROM, past the file system. I like to call this part of the ROM the TWL region, while everything before it is the NTR region.

Because they are not needed to boot games in DS mode, most tools completely disregard these binaries or place them wrongly when rebuilding ROMs; the only one which handles them somewhat correctly is TinkeDSi. Unlike the rest of the rom which is aligned to 0x200, TWL binaries are aligned to 0x80000. In fact, at header[0x90] there are two 2-byte values which are set to <start of the TWL region> >> 0x13 and they must match the actual start of the TWL region, otherwise official software will refuse to start the ROM.

HMAC

The final part of the header contains the first integrity mechanism employed by DSi-enhanced games, which is an array of SHA-1 HMAC digests over several different parts of the ROM. The algorithm used to calculate them is just a regular SHA-1 HMAC with a constant HMAC key that is shared between retail and debug applications. The following HMAC digests are present:

  • ARM9 SHA-1 HMAC including encrypted secure area (0x4000 and up)
  • ARM7 SHA-1 HMAC
  • Digest master hash (see "digest sectors" below)
  • Icon/title SHA-1 HMAC
  • ARM9i SHA-1 HMAC (decrypted, see "Modcrypt" below)
  • ARM7i SHA-1 HMAC (also decrypted)
  • ARM9 SHA-1 HMAC excluding the entire secure area, so only from 0x8000 onwards.

Modcrypt

In addition to the already existing encrypted secure area at the start of ARM9, which is encrypted using Blowfish, DSi games also introduce a different type of encryption called Modcrypt, which is in essence a slightly modified 128-bit AES-CTR stream cipher. The header points to two different Modcrypt areas, however in my experience only area1 is ever used, and it is always placed at the start of ARM9i. Areas can be of size >= 0x4000, but the minimum size is used most of the time.

A Modcrypt cipher is defined by two values, key and IV. Both of these actually depend on data contained in the ROM itself, therefore Modcrypt does not provide any kind of secrecy, only obfuscation. For debug applications, the key is simply the first 16 bytes of the header. For retail games, the key is defined as follows:

KeyDSi = (((KeyX) XOR KeyY) + FFFEFB4E295902582A680F5F1A4F3E79h) ROL 42

Where KeyX and KeyY are derived as:

KeyX = str.encode("Nintendo", encoding='ascii') + gamecode + emagcode
KeyY = bytes(arm9ihmac[:16])

The gamecode is the 4-letter identifier found at header[0xC] while the ARM9i HMAC is the one from the header. The emagcode is just the reversed gamecode.

The IV is the same in both retail and debug, but different for each area. area1 uses the first 16 bytes of the ARM9 HMAC (with encrypted secure area), while area2 uses the first 16 bytes of the ARM7 HMAC.

Digest sectors

At the end of the NTR region, two additional data structures are appended which verify the integrity of the entire ROM. GBATEK calls these the "digest sector" and "digest block", but to be more clear I simply call them digest1 and digest2. Their definition is a bit confusing, so I'll describe them in the order in which they are calculated.

First of all, the header points to two digest regions, one for NTR and one for TWL. They usually, but not necessarily, match the entire TWL and NTR regions, minus the digest sector itself obviously. These regions (excluding modcrypt encryption, but including secure area encryption!) are concatenated, then split into sectorSize blocks, where sectorSize is usually 0x400 but otherwise defined in the header. Then each block is hashed and a SHA-1 HMAC is produced. All these hashes are then put together as part of digest1. digest1 is then split again into blocks of size 20 * sectorsPerBlock, which is also defined in the header but usually is 0x20. Each block is hashed again, and the resulting "hashes of hashes" are combined to form digest2. digest1 and digest2 are then placed one after the other at the end of the NTR region. Any extra space at the end of each digest is padded with 0x00 bytes instead of the usual 0xFF used everywhere else.

Finally, the entirety of digest2 is hashed to produce one final SHA-1 HMAC, called the digest master, which is placed together with the other SHA-1 HMACs in the header. This nested structure ensures that the digest master basically commits to the entire rom, becoming invalid if even one single byte is changed.

TWL Blowfish tables

This is a bit of a weird one, as I don't know exactly how they work and they are basically undocumented, but there is a 0x3000 region right before the start of the ARM9i binary which contains some kind of Blowfish table. This is similar to what is contained in the no-load area of NDS roms (0x1000-0x3000) which is empty in dumps, but in real ROMs and official .SRL files contains some test patterns and Blowfish tables used to decrypt cartridge commands. In DSi ROMs, this part is entirely useless except for one thing, and that is making the System Menu recognize the game as bootable; if they are missing, it will show the "no cartridge" icon. Fortunately, these depend entirely on gamecode for retail games, so we can just leave them untouched; for debug roms, they seem to be always the same, so they can be taken from any debug rom.

RSA signature

The first 0xE00 bytes of the header are signed with a 1024-bit RSA private key, which is different between retail and debug. The retail key was kept secret by Nintendo and currently unobtainable, while the debug key is included in the TWL SDK tools. The public keys are instead included in the BIOS and are used to verify the validity of the signature.

The signature is 0x80 bytes long and is generated as a regular SHA-1 over the header bytes, which is then padded according to the PKCS#1.5 standard, but without ASN.1 encoding. The resulting digest is encrypted by doing a "raw" RSA encryption using the private exponent.

The signature is the final step in the chain of integrity checks within a DSi-enhanced ROM; the signature commits to the header, which contains the HMACs and the master digest, which in turn verifies the digest sectors, which verify the whole ROM. This makes it effectively impossible to modify a single byte without having the firmware notice and abort loading the ROM.

The keys

The keys can be found in various places, this is how I recovered them:

  • Retail RSA public key: starts with 95 6F 79 0D and can be found in the DSi BIOS.
  • Debug RSA keypair: can be extracted from the SDK tool makerom.TWL. Should be detected by tools like binwalk as a DER-encoded keypair, and the raw bytes can be extracted as a .der file. The public key starts with AC 93 BB 3C, the private key with 95 DC C8 18.
  • HMAC key: found in the BIOS or launcher. Should also be in makerom.TWL. Starts with 21 06 C0 DE and is 64-byte long. if it's longer, make sure there are no extra 00s interleaved within it.
  • Blowfish key blob: starts with 99 D5 20 5F and is 0x1048 bytes long. Found in the BIOS as well as makerom.TWL.

DER-encoded keypairs can be converted using the openssl CLI tool into a pair of PEM files for easier use. To extract the private key in PEM format, do:

openssl pkcs8 -inform DER -in debug.der -nocrypt -out private.pem

Then to derive the public key use the command:

openssl rsa -in private.pem -pubout -out public.pem

How to convert a retail ROM into debug

There are two separate checks done on debug ROMs (or, more appropriately, .SRL files). The first one is done by the IS-TWL-DEBUGGER software, which checks bit 7 of header[0x1BF] (developer application flag). If it's 0, it will refuse to send the SRL file over to the debugger. The second check is the integrity check performed by the console itself, over the same structures as retail consoles but using debug keys. If the check fails, the CPU will halt and the bottom screen will show an error message.

To make the IS-TWL-DEBUGGER to load a retail ROM as an SRL file, it must be modified like this:

  • Make sure the secure area is encrypted
  • Set developer application: rom[0x1BF] |= 0x80
  • Write the NTR Blowfish tables and test patterns at 0x1000-0x4000. They can be taken from a ROM encrypted with ndstool -se, as long as the gamecode is the same.
  • Write the TWL Blowfish tables at [arm9i_start - 0x3000]. They can be copied from any debug rom generated by makerom.TWL.
  • Decrypt area1 using the retail key and IV.
  • Encrypt area1 using the debug key and IV.
  • Generate the SHA-1 of the first 0xE00 bytes of the header, encode it as PKCS#1.5, and encrypt it with the debug RSA private key, then place it at header[0xF80].

This is enough if you make no modification to the ROM. Otherwise you will also need to:

  • Adjust the header as needed, and fix the header CRC
  • Optionally fix the secure area CRC if you modified it
  • Regenerate digest1 and digest2 using the decrypted TWL region
  • Recalculate the SHA-1 HMAC of the regions you changed.

A note about Pokémon games

Gen 5 Pokémon games use a different cartridge which includes an IR module. If you try to run them off a dev cartridge without the IR module, this causes the game to not detect the save flash correctly. To fix this, you need to patch the ARM7 binary in this way:

# Disable checking for IR chip when accessing backup memory
# beq #0x40 -> b #0x40
arm7_patch_F170 = b"\x0e\x00\x00\xea"
arm7[0xF170:0xF174] = arm7_patch_F170

Sample scripts

Below you can find some Python scripts I wrote to resign Pokémon BW and B2W2, I made them for my personal use so they may not work for all ROMs or on all systems, but I think you can easily edit them to suit your needs.

Sources & credits

  • GBATEK
  • Ekona (.NET library)
  • The source code of various programs such as TinkeDSi and ndstool

Big thanks to xp for helping me with figuring out some of this stuff, especially the digest sectors.

# Put your own keys here (I don't want to distribute them for extra safety)
# Disable checking for IR chip when accessing backup memory
# beq #0x40 -> b #0x40
arm7_patch_F170 = b"\x0e\x00\x00\xea"
hmac_key = bytes.fromhex("redacted")
retailPublicModulus = bytes.fromhex("redacted")
# Taken from ndstool
crcTable = [
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
]
# Blowfish table
blowfish_key_table = bytes([
# Redacted
])
# I vibecoded this so it's kinda bad but it works
from const import *
import struct
from io import BufferedReader, BytesIO
UINT32_MASK = 0xFFFFFFFF
class NitroBlowfish:
KEY_WORDS = (0x12 + 0x400) # 1042 words
KEY_LENGTH_BYTES = KEY_WORDS * 4 # 4168 bytes
def __init__(self):
# key buffer stores 1042 uint32 values
self.key_buffer = [0] * NitroBlowfish.KEY_WORDS
@staticmethod
def _to_uint32(x: int) -> int:
return x & UINT32_MASK
def initialize(self, id_text: str, level: int, modulo: int, key_table: bytes) -> None:
"""
Initialize the cipher.
id_text: 4-character string
level: 1..3
modulo: 4, 8 or 12
key_table:
"""
# validate id_text
if id_text is None or len(id_text) != 4:
raise ValueError("id_text must be a 4-character string")
# validate modulo and level
if modulo not in (4, 8, 12):
raise ValueError("modulo must be 4, 8 or 12")
if level < 1 or level > 3:
raise ValueError("level must be 1, 2 or 3")
if len(key_table) != NitroBlowfish.KEY_LENGTH_BYTES:
raise ValueError(f"key_table bytes length must be {NitroBlowfish.KEY_LENGTH_BYTES}")
# unpack 1042 little-endian uint32 values
fmt = '<{}I'.format(NitroBlowfish.KEY_WORDS)
self.key_buffer = list(struct.unpack_from(fmt, key_table, 0))
# ensure 32-bit wrap
self.key_buffer = [self._to_uint32(x) for x in self.key_buffer]
# Build idCode and keyCode like in C#
# C# did: idCode = ((byte)idText[3] << 24) | ((byte)idText[2] << 16) | ((byte)idText[1] << 8) | ((byte)idText[0] << 0)
b = id_text.encode('latin1') # take raw bytes of the 4 chars
id_code = ((b[3] << 24) | (b[2] << 16) | (b[1] << 8) | (b[0] << 0)) & UINT32_MASK
key_code = [0, 0, 0]
key_code[0] = id_code
key_code[1] = (id_code >> 1) & UINT32_MASK
key_code[2] = (id_code << 1) & UINT32_MASK
# Apply key code(s)
self._apply_key_code(modulo, key_code) # level 1 always
if level >= 2:
self._apply_key_code(modulo, key_code)
key_code[1] = self._to_uint32(key_code[1] << 1)
key_code[2] = self._to_uint32(key_code[2] >> 1)
if level >= 3:
self._apply_key_code(modulo, key_code)
def encrypt_block(self, data0: int, data1: int) -> tuple[int, int]:
"""Encrypt two uint32 words and return (new_data0, new_data1)."""
x = self._to_uint32(data1)
y = self._to_uint32(data0)
for i in range(0x10): # 0..15
z = self._to_uint32(self.key_buffer[i] ^ x)
x = self._to_uint32(self._get_mixer(z) ^ y)
y = z
data0 = self._to_uint32(x ^ self.key_buffer[0x10])
data1 = self._to_uint32(y ^ self.key_buffer[0x11])
return data0, data1
def decrypt_block(self, data0: int, data1: int) -> tuple[int, int]:
"""Decrypt two uint32 words and return (new_data0, new_data1)."""
x = self._to_uint32(data1)
y = self._to_uint32(data0)
# i from 0x11 down to 0x2 inclusive
for i in range(0x11, 1, -1):
z = self._to_uint32(self.key_buffer[i] ^ x)
x = self._to_uint32(self._get_mixer(z) ^ y)
y = z
data0 = self._to_uint32(x ^ self.key_buffer[0x01])
data1 = self._to_uint32(y ^ self.key_buffer[0x00])
return data0, data1
def encrypt_bytes(self, data: bytes) -> bytes:
"""Encrypts full 8-byte blocks from data and returns new bytes (partial tail is ignored)."""
return self._array_encryption(True, data)
def decrypt_bytes(self, data: bytes) -> bytes:
"""Decrypts full 8-byte blocks from data and returns new bytes (partial tail is ignored)."""
return self._array_encryption(False, data)
def encrypt_stream(self, stream: BufferedReader) -> None:
"""Encrypt the given stream in-place over 8-byte blocks."""
self._stream_encryption(True, stream)
def decrypt_stream(self, stream: BufferedReader) -> None:
"""Decrypt the given stream in-place over 8-byte blocks."""
self._stream_encryption(False, stream)
# ---------- internal helpers ----------
def _stream_encryption(self, encrypt: bool, stream) -> None:
"""
Overwrites stream from position 0 with encrypted/decrypted data.
Only processes whole 8-byte blocks; leaves remainder untouched.
"""
if stream is None:
raise ValueError("stream is None")
stream.seek(0)
# use a buffer to read and write
while True:
pos = stream.tell()
chunk = stream.read(8)
if len(chunk) < 8:
break
data0, data1 = struct.unpack_from('<II', chunk, 0)
if encrypt:
data0, data1 = self.encrypt_block(data0, data1)
else:
data0, data1 = self.decrypt_block(data0, data1)
stream.seek(pos)
stream.write(struct.pack('<II', data0, data1))
def _array_encryption(self, encrypt: bool, data: bytes) -> bytes:
out = bytearray(len(data))
i = 0
while i + 8 <= len(data):
data0, data1 = struct.unpack_from('<II', data, i)
if encrypt:
data0, data1 = self.encrypt_block(data0, data1)
else:
data0, data1 = self.decrypt_block(data0, data1)
out[i:i+4] = struct.pack('<I', data0)
out[i+4:i+8] = struct.pack('<I', data1)
i += 8
return bytes(out)
def _apply_key_code(self, modulo: int, key_code: list) -> None:
# Reverse endianness function used only for XOR stage
def reverse_endianness(value: int) -> int:
# swap bytes
b0 = (value >> 24) & 0xFF
b1 = (value >> 16) & 0xFF
b2 = (value >> 8) & 0xFF
b3 = value & 0xFF
return ((b3 << 24) | (b2 << 16) | (b1 << 8) | b0) & UINT32_MASK
# The two Encrypt calls on the key_code array (in-place)
kc = key_code
kc[1], kc[2] = self.encrypt_block(kc[1], kc[2])
kc[0], kc[1] = self.encrypt_block(kc[0], kc[1])
# XOR stage
m = modulo // 4
# loop i = 0 to (0x44 / 4) inclusive => 0..17 (0x44 == 68 decimal; 68/4=17)
for i in range(0, (0x44 // 4) + 1):
xor_value = reverse_endianness(kc[i % m])
self.key_buffer[i] = self._to_uint32(self.key_buffer[i] ^ xor_value)
# Re-key the rest of key_buffer via encrypting a running scratch pair
scratch0 = 0
scratch1 = 0
# loop i = 0 to (0x1040 / 4) inclusive stepping by 2
# 0x1040 = 4160 decimal; 4160 / 4 = 1040 => iterate i=0..1040 inclusive step 2 -> covers up to index 1040+1=1041
for i in range(0, (0x1040 // 4) + 1, 2):
scratch0, scratch1 = self.encrypt_block(scratch0, scratch1)
# written in C# as keyBuffer[i] = scratch1; keyBuffer[i+1] = scratch0;
self.key_buffer[i] = self._to_uint32(scratch1)
self.key_buffer[i + 1] = self._to_uint32(scratch0)
def _get_mixer(self, index_value: int) -> int:
# Uses indices 0x12 + byte0..3 lookups
a = self.key_buffer[0x12 + ((index_value >> 24) & 0xFF)]
b = self.key_buffer[0x112 + ((index_value >> 16) & 0xFF)]
c = self.key_buffer[0x212 + ((index_value >> 8) & 0xFF)]
d = self.key_buffer[0x312 + (index_value & 0xFF)]
# C# does: value += a; value += b; value ^= c; value += d; (with wrapping)
value = self._to_uint32(a)
value = self._to_uint32(value + b)
value = self._to_uint32(value ^ c)
value = self._to_uint32(value + d)
return value
def encrypt_secure_area(area, gamecode):
gamecode = bytes.decode(bytes(gamecode), encoding='ascii')
encryption = NitroBlowfish()
encryption.initialize(gamecode, 3, 8, blowfish_key_table)
data_stream = BytesIO(area)
encryption.encrypt_stream(data_stream)
area = bytearray(data_stream.getvalue())
encryption.initialize(gamecode, 2, 8, blowfish_key_table)
area[0:8] = encryption.encrypt_bytes(area[0:8])
return area
def decrypt_secure_area(area, gamecode):
gamecode = bytes.decode(bytes(gamecode), encoding='ascii')
encryption = NitroBlowfish()
encryption.initialize(gamecode, 2, 8, blowfish_key_table)
area[0:8] = encryption.decrypt_bytes(area[0:8])
data_stream = BytesIO(area)
encryption.initialize(gamecode, 3, 8, blowfish_key_table)
encryption.decrypt_stream(data_stream)
return bytearray(data_stream.getvalue())
from const import *
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import io
class Modcrypt:
def __init__(self, key: bytes, iv: int):
self.key = key
self.counter = iv
self.block_size = 16
self.backend = default_backend()
def transform(self, input_stream: io.BytesIO) -> io.BytesIO:
output = io.BytesIO()
cipher = Cipher(algorithms.AES(self.key), modes.ECB(), backend=self.backend)
encryptor = cipher.encryptor()
while True:
buffer = input_stream.read(self.block_size)
if not buffer:
break
xor_mask = encryptor.update(self.counter.to_bytes(16))
self.counter = (self.counter + 1) % 2**128
# XOR with reversed xor_mask
xored = bytes(b ^ xor_mask[self.block_size - 1 - i] for i, b in enumerate(buffer))
output.write(xored)
return output
def derive_retail_key(gamecode: str, arm9ihmac: bytes) -> bytes:
emagcode = gamecode[::-1]
scrambler_bytes = bytes([0x79, 0x3E, 0x4F, 0x1A, 0x5F, 0x0F, 0x68, 0x2A, 0x58, 0x02, 0x59, 0x29, 0x4E, 0xFB, 0xFE, 0xFF])
mask = 0x3FFFFFFFFFF
# KeyDSi = (((KeyX) XOR KeyY) + FFFEFB4E295902582A680F5F1A4F3E79h) ROL 42
retail_keyX = str.encode("Nintendo", encoding='ascii') + gamecode + emagcode
retail_keyY = bytes(arm9ihmac[:16])
nX = int.from_bytes(retail_keyX, byteorder='little')
nY = int.from_bytes(retail_keyY, byteorder='little')
scrambler_n = int.from_bytes(scrambler_bytes, byteorder='little')
key_n = ((nX ^ nY) + scrambler_n)
shifted_key_n = key_n << 42
rotated_key = (shifted_key_n | ((shifted_key_n >> 128) & mask)) & (2**128 - 1)
retail_key = rotated_key.to_bytes(16)
return retail_key
def modcrypt_transform(area: bytearray, key: bytes, iv: int) -> bytes:
modcrypt = Modcrypt(key, iv)
data_stream = io.BytesIO(area)
output_stream = modcrypt.transform(data_stream)
transform_area = output_stream.getvalue()
return transform_area
# Usage: python resign.py input.nds output.nds
# Make sure to check the path to your ndstool executable, I run it through WSL
from const import *
from blowfish import *
from modcrypt import *
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import subprocess
import hashlib
import hmac
import sys
# Taken from ndstool
def calc_crc16(data: bytearray) -> bytes:
crc = 0xFFFF
for b in data:
crc = (crc >> 8) ^ crcTable[(crc ^ b) & 0xFF]
return crc.to_bytes(length=2, byteorder='little')
EXPECTED_ROM_SIZE = 512 * 1024 * 1024
### Data preparation ##########################################################
# Load keys
public_key = serialization.load_pem_public_key(
open("public.pem", "rb").read(),
backend=default_backend()
)
private_key = serialization.load_pem_private_key(
open("private.pem", "rb").read(),
None,
backend=default_backend()
)
# Read rom files
# Clean retail dump (encrypted) or apfixed dump
path = sys.argv[1]
with open(path, 'rb') as f:
rom = bytearray(f.read())
rom_size = len(rom)
# Any beta/debug rom
with open("swan_debug.srl", 'rb') as f:
debug_rom = bytearray(f.read())
# Decrypt and re-encrypt with ndstool to generate test patterns
with open("temp.nds", 'wb') as f:
f.write(rom)
subprocess.run(["wsl", "-e", "/opt/devkitpro/tools/bin/ndstool", "-sd", "temp.nds"], check=True)
subprocess.run(["wsl", "-e", "/opt/devkitpro/tools/bin/ndstool", "-se", "temp.nds"], check=True)
with open("temp.nds", 'rb') as f:
ndstool_rom = bytearray(f.read())
# Read ARM binaries
arm9i_offset = int.from_bytes(rom[0x1C0:0x1C4], byteorder='little')
arm9i_size = int.from_bytes(rom[0x1CC:0x1D0], byteorder='little')
arm7i_offset = int.from_bytes(rom[0x1D0:0x1D4], byteorder='little')
arm7i_size = int.from_bytes(rom[0x1DC:0x1E0], byteorder='little')
arm9_offset = int.from_bytes(rom[0x20:0x24], byteorder='little')
arm9_size = int.from_bytes(rom[0x2C:0x30], byteorder='little')
arm7_offset = int.from_bytes(rom[0x30:0x34], byteorder='little')
arm7_size = int.from_bytes(rom[0x3C:0x40], byteorder='little')
# Read digest regions (digest1 and digest2)
digest_region_ntr_offset = int.from_bytes(rom[0x1E0:0x1E4], byteorder='little')
digest_region_ntr_length = int.from_bytes(rom[0x1E4:0x1E8], byteorder='little')
digest_region_twl_offset = int.from_bytes(rom[0x1E8:0x1EC], byteorder='little')
digest_region_twl_length = int.from_bytes(rom[0x1EC:0x1F0], byteorder='little')
digest1_offset = int.from_bytes(rom[0x1F0:0x1F4], byteorder='little')
digest1_length = int.from_bytes(rom[0x1F4:0x1F8], byteorder='little')
digest2_offset = int.from_bytes(rom[0x1F8:0x1FC], byteorder='little')
digest2_length = int.from_bytes(rom[0x1FC:0x200], byteorder='little')
digest1_sector_size = int.from_bytes(rom[0x200:0x204], byteorder='little')
digest2_sectors_per_block = int.from_bytes(rom[0x204:0x208], byteorder='little')
# Read SHA-1-HMACs
arm9_enc_sha1_hmac = rom[0x300:0x314] # With encrypted secure area
arm7_sha1_hmac = rom[0x314:0x328]
master_digest = rom[0x328:0x33C]
icon_digest = rom[0x33C:0x350]
arm9i_sha1_hmac = rom[0x350:0x364] # Decrypted
arm7i_sha1_hmac = rom[0x364:0x378] # Decrypted
arm9_dec_sha1_hmac = rom[0x3A0:0x3B4] # Decrypted
# Read modcrypt metadata
modcrypt_area1_offset = int.from_bytes(rom[0x220:0x224], byteorder='little')
modcrypt_area1_size = int.from_bytes(rom[0x224:0x228], byteorder='little')
if modcrypt_area1_offset < arm9i_offset:
print("Warning: area1 offset is lower than arm9i offset. Using arm9i offset instead")
print(f"area1 offset = 0x{modcrypt_area1_offset:02X}")
print(f"arm9i offset = 0x{arm9i_offset:02X}")
modcrypt_area1_offset = arm9i_offset
rom[0x220:0x224] = modcrypt_area1_offset.to_bytes(length=4, byteorder='little')
modcrypt_area2_offset = int.from_bytes(rom[0x228:0x22C], byteorder='little')
modcrypt_area2_size = int.from_bytes(rom[0x22C:0x230], byteorder='little')
# Game codes
gamecode = rom[0xC:0x10]
emagcode = gamecode[::-1]
# Calculate Modcrypt AES-CTR keys
debug_key = rom[:16]
debug_key.reverse()
iv_area1 = int.from_bytes(arm9_enc_sha1_hmac[:16], byteorder='little')
iv_area2 = int.from_bytes(arm7_sha1_hmac[:16], byteorder='little')
retail_key = derive_retail_key(gamecode, arm9i_sha1_hmac)
# ROM data end
rom_data_size = int.from_bytes(rom[0x210:0x214], byteorder='little')
### Applying changes ##########################################################
# Set developer application
rom[0x1BF] |= 0x80
# Set access control dword
rom[0x1B4:0x1B8] = debug_rom[0x1B4:0x1B8]
# Copy 0x1000-0x4000 area generated by ndstool -se
# Contains NTR Blowfish tables and test patterns
rom[0x1000:0x4000] = ndstool_rom[0x1000:0x4000]
# Make sure that arm9i/arm7i are aligned to 0x80000
twl_tables_region = arm9i_offset - 0x3000
if twl_tables_region % 0x80000 != 0:
old_arm9i = arm9i_offset
old_arm7i = arm7i_offset
twl_tables_region = 0x80000 * (1 + (twl_tables_region // 0x80000))
# Adjust header
shift = twl_tables_region + 0x3000 - arm9i_offset
arm7i_offset += shift
arm9i_offset += shift
modcrypt_area1_offset += shift
rom[0x1C0:0x1C4] = arm9i_offset.to_bytes(length=4, byteorder='little')
rom[0x1D0:0x1D4] = arm7i_offset.to_bytes(length=4, byteorder='little')
rom[0x220:0x224] = modcrypt_area1_offset.to_bytes(length=4, byteorder='little')
# Adjust data
twl_rom = rom[old_arm9i:]
rom = rom[:old_arm9i] + bytearray([0xFF] * shift) + twl_rom
# Copy the TWL Blowfish tables from the debug rom
arm9i_debug_off = int.from_bytes(debug_rom[0x1C0:0x1C4], byteorder='little')
twl_tbl_debug = arm9i_debug_off - 0x3000
rom[twl_tables_region:arm9i_offset] = debug_rom[twl_tbl_debug:arm9i_debug_off]
# Adjust TWL/NTR ROM offset based on the TWL tables position
rom_start = twl_tables_region // 0x80000
rom_start_bytes = rom_start.to_bytes(length=2, byteorder='little')
rom[0x90:0x92] = rom_start_bytes
rom[0x92:0x94] = rom_start_bytes
# Patch arm7
arm7 = rom[arm7_offset:arm7_offset+arm7_size]
arm7[0xF170:0xF174] = arm7_patch_F170
# rom[arm7_offset:arm7_offset+arm7_size] = arm7
# Fix secure area CRC
secure_area_crc = calc_crc16(rom[arm9_offset:arm9_offset+0x4000])
rom[0x6C:0x6E] = secure_area_crc
# Fix header CRC
new_crc = calc_crc16(rom[0x0:0x15E])
rom[0x15E:0x160] = new_crc
# Check digest region lengths, shrink NTR region if too large
total_size = digest_region_ntr_length + digest_region_twl_length
expected_size = (digest1_length // 20) * digest1_sector_size
if total_size > expected_size:
diff = total_size - expected_size
digest_region_ntr_length -= diff
rom[0x1E4:0x1E8] = digest_region_ntr_length.to_bytes(length=4, byteorder='little')
# Digest regions (including changes)
ntr_region = rom[digest_region_ntr_offset:digest_region_ntr_offset+digest_region_ntr_length]
twl_region = rom[digest_region_twl_offset:digest_region_twl_offset+digest_region_twl_length]
# Decrypt TWL region for digest calculation
decrypted = modcrypt_transform(twl_region[:0x4000], retail_key, iv_area1)
twl_region[:0x4000] = bytearray(decrypted)
# Generate digest1
digest_region = ntr_region + twl_region
n_sectors = len(digest_region) // digest1_sector_size
calc_digest1 = bytearray()
for i in range(0, n_sectors):
digest = hmac.new(hmac_key, digest_region[i * digest1_sector_size:(i+1) * digest1_sector_size], hashlib.sha1)
hash = bytearray(digest.digest())
calc_digest1 += hash
diff = digest1_length - len(calc_digest1)
if diff > 0:
calc_digest1 += bytearray([0 for _ in range(diff)])
# Generate digest2
calc_digest2 = bytearray()
bytes_per_block = 20 * digest2_sectors_per_block
for i in range(0, digest1_length, bytes_per_block):
digest = hmac.new(hmac_key, calc_digest1[i:i+bytes_per_block], hashlib.sha1)
hash = bytearray(digest.digest())
calc_digest2 += hash
# Write digest blocks to rom
rom[digest1_offset:digest1_offset+digest1_length] = calc_digest1
rom[digest2_offset:digest2_offset+digest2_length] = calc_digest2
# Read arm9 after changes
arm9 = rom[arm9_offset:arm9_offset+arm9_size]
# ARM9 HMAC with secure area
enc_arm9_hmac = hmac.new(hmac_key, arm9, hashlib.sha1)
rom[0x300:0x314] = enc_arm9_hmac.digest()
# ARM7 HMAC
arm7_hmac = hmac.new(hmac_key, arm7, hashlib.sha1)
rom[0x314:0x328] = arm7_hmac.digest()
# Generate digest master
master_hmac = hmac.new(hmac_key, calc_digest2, hashlib.sha1)
rom[0x328:0x33C] = master_hmac.digest()
# Icon HMAC
icon_offset = int.from_bytes(rom[0x68:0x6C], byteorder='little')
icon_size = int.from_bytes(rom[0x208:0x20C], byteorder='little')
icon_data = rom[icon_offset:icon_offset+icon_size]
icon_digest = hmac.new(hmac_key, icon_data, hashlib.sha1)
rom[0x33C:0x350] = icon_digest.digest()
# ARM9i HMAC
# TODO: handle nonstandard modcrypt area1
arm9i = rom[arm9i_offset:arm9i_offset+arm9i_size]
dec_arm9i = bytearray(decrypted) + arm9i[0x4000:]
arm9i_digest = hmac.new(hmac_key, dec_arm9i, hashlib.sha1)
rom[0x350:0x364] = arm9i_digest.digest()
# ARM7i HMAC
# TODO: Handle modcrypt-encrypted ARM7i
arm7i = rom[arm7i_offset:arm7i_offset+arm7i_size]
arm7i_digest = hmac.new(hmac_key, arm7i, hashlib.sha1)
rom[0x364:0x378] = arm7i_digest.digest()
# ARM9 HMAC without secure area
dec_arm9_hmac = hmac.new(hmac_key, arm9[0x4000:], hashlib.sha1)
rom[0x3A0:0x3B4] = dec_arm9_hmac.digest()
# Since HMACs have changed, we must use new IVs for encryption!
new_iv_area1 = int.from_bytes(enc_arm9_hmac.digest()[:16], byteorder='little')
new_iv_area2 = int.from_bytes(arm7_hmac.digest()[:16], byteorder='little')
# Re encrypt and write Modcrypt areas to ROM
if modcrypt_area1_offset != 0:
area1 = rom[modcrypt_area1_offset:modcrypt_area1_offset + modcrypt_area1_size]
dec_area1 = modcrypt_transform(area1, retail_key, iv_area1)
debug_area1 = modcrypt_transform(dec_area1, debug_key, new_iv_area1)
rom[modcrypt_area1_offset:modcrypt_area1_offset + modcrypt_area1_size] = debug_area1
if modcrypt_area2_offset != 0:
area2 = rom[modcrypt_area1_offset:modcrypt_area2_offset + modcrypt_area2_size]
dec_area2 = modcrypt_transform(area2, retail_key, iv_area2)
debug_area2 = modcrypt_transform(dec_area2, debug_key, new_iv_area2)
rom[modcrypt_area2_offset:modcrypt_area2_offset + modcrypt_area2_size] = debug_area2
# Calculate header digest (PKCS#1 v1.5 without ASN.1 encoding)
header_entries = rom[:0xE00]
sha1_digest = hashlib.sha1(header_entries).digest()
em = b'\x00' + b'\x01' + b'\xFF' * 105 + b'\x00' + sha1_digest
assert len(em) == 128
# Get the key numbers
pub_numbers = public_key.public_numbers()
n = pub_numbers.n
e = pub_numbers.e
priv_numbers = private_key.private_numbers()
d = priv_numbers.d
# Raw RSA encryption
em_int = int.from_bytes(em, byteorder='big')
encrypted_int = pow(em_int, d, n)
sig = encrypted_int.to_bytes(128, byteorder='big')
# Write signature to rom
rom[0xF80:0x1000] = sig
# Padding
if len(rom) < EXPECTED_ROM_SIZE:
diff = EXPECTED_ROM_SIZE - len(rom)
padding = bytearray([0xFF] * diff)
rom += padding
out_path = sys.argv[2]
with open(out_path, 'wb') as f:
f.write(rom)
# Usage: python verify.py rom.nds
from const import *
from modcrypt import *
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
# from textwrap import wrap
import hashlib
import hmac
import sys
public_key = serialization.load_pem_public_key(
open("public.pem", "rb").read(),
backend=default_backend()
)
path = sys.argv[1]
with open(path, 'rb') as f:
rom = bytearray(f.read())
is_debug = (rom[0x1BF] & 0x80 != 0)
# Read ARM binaries
arm9i_offset = int.from_bytes(rom[0x1C0:0x1C4], byteorder='little')
arm9i_size = int.from_bytes(rom[0x1CC:0x1D0], byteorder='little')
arm7i_offset = int.from_bytes(rom[0x1D0:0x1D4], byteorder='little')
arm7i_size = int.from_bytes(rom[0x1DC:0x1E0], byteorder='little')
arm9_offset = int.from_bytes(rom[0x20:0x24], byteorder='little')
arm9_size = int.from_bytes(rom[0x2C:0x30], byteorder='little')
arm7_offset = int.from_bytes(rom[0x30:0x34], byteorder='little')
arm7_size = int.from_bytes(rom[0x3C:0x40], byteorder='little')
# Read digest regions (digest1 and digest2)
digest_region_ntr_offset = int.from_bytes(rom[0x1E0:0x1E4], byteorder='little')
digest_region_ntr_length = int.from_bytes(rom[0x1E4:0x1E8], byteorder='little')
digest_region_ntr = rom[digest_region_ntr_offset:digest_region_ntr_offset+digest_region_ntr_length]
digest_region_twl_offset = int.from_bytes(rom[0x1E8:0x1EC], byteorder='little')
digest_region_twl_length = int.from_bytes(rom[0x1EC:0x1F0], byteorder='little')
digest_region_twl = rom[digest_region_twl_offset:digest_region_twl_offset+digest_region_twl_length]
digest1_offset = int.from_bytes(rom[0x1F0:0x1F4], byteorder='little')
digest1_length = int.from_bytes(rom[0x1F4:0x1F8], byteorder='little')
digest1 = rom[digest1_offset:digest1_offset+digest1_length]
digest2_offset = int.from_bytes(rom[0x1F8:0x1FC], byteorder='little')
digest2_length = int.from_bytes(rom[0x1FC:0x200], byteorder='little')
digest2 = rom[digest2_offset:digest2_offset+digest2_length]
digest1_sector_size = int.from_bytes(rom[0x200:0x204], byteorder='little')
digest2_sectors_per_block = int.from_bytes(rom[0x204:0x208], byteorder='little')
icon_offset = int.from_bytes(rom[0x68:0x6C], byteorder='little')
icon_size = int.from_bytes(rom[0x208:0x20C], byteorder='little')
modcrypt_area1_offset = int.from_bytes(rom[0x220:0x224], byteorder='little')
modcrypt_area1_size = int.from_bytes(rom[0x224:0x228], byteorder='little')
# Read SHA-1-HMACs
arm9_enc_sha1_hmac = rom[0x300:0x314] # With encrypted secure area
arm7_sha1_hmac = rom[0x314:0x328]
master_digest = rom[0x328:0x33C]
icon_digest = rom[0x33C:0x350]
arm9i_sha1_hmac = rom[0x350:0x364] # Decrypted
arm7i_sha1_hmac = rom[0x364:0x378] # Decrypted
arm9_dec_sha1_hmac = rom[0x3A0:0x3B4] # Decrypted
# Game codes
gamecode = rom[0xC:0x10]
emagcode = gamecode[::-1]
# Calculate Modcrypt AES-CTR keys
debug_key = rom[:16]
debug_key.reverse()
iv_area1 = int.from_bytes(arm9_enc_sha1_hmac[:16], byteorder='little')
iv_area2 = int.from_bytes(arm7_sha1_hmac[:16], byteorder='little')
retail_key = derive_retail_key(gamecode, arm9i_sha1_hmac)
key = debug_key if is_debug else retail_key
# Check digest blocks
# Digest regions
ntr_region = rom[digest_region_ntr_offset:digest_region_ntr_offset+digest_region_ntr_length]
twl_region = rom[digest_region_twl_offset:digest_region_twl_offset+digest_region_twl_length]
# Decrypt TWL region for digest calculation
decrypted = modcrypt_transform(rom[modcrypt_area1_offset:modcrypt_area1_offset+modcrypt_area1_size], key, iv_area1)
twl_region[:0x4000] = bytearray(decrypted)
# Generate digest1
digest_region = ntr_region + twl_region
n_sectors = len(digest_region) // digest1_sector_size
calc_digest1 = bytearray()
for i in range(0, n_sectors):
digest = hmac.new(hmac_key, digest_region[i * digest1_sector_size:(i+1) * digest1_sector_size], hashlib.sha1)
hash = bytearray(digest.digest())
calc_digest1 += hash
diff = digest1_length - len(calc_digest1)
if diff > 0:
calc_digest1 += bytearray([0 for _ in range(diff)])
# Generate digest2
calc_digest2 = bytearray()
bytes_per_block = 20 * digest2_sectors_per_block
for i in range(0, digest1_length, bytes_per_block):
digest = hmac.new(hmac_key, calc_digest1[i:i+bytes_per_block], hashlib.sha1)
hash = bytearray(digest.digest())
calc_digest2 += hash
if digest1 == calc_digest1:
print("✅ digest1 OK")
else:
print("❌ digest1 fail")
if digest2 == calc_digest2:
print("✅ digest2 OK")
else:
print("❌ digest2 fail")
# Check header HMACs
arm9 = rom[arm9_offset:arm9_offset+arm9_size]
enc_arm9_hmac = hmac.new(hmac_key, arm9, hashlib.sha1)
arm7 = rom[arm7_offset:arm7_offset+arm7_size]
arm7_hmac = hmac.new(hmac_key, arm7, hashlib.sha1)
master_hmac = hmac.new(hmac_key, calc_digest2, hashlib.sha1)
icon_data = rom[icon_offset:icon_offset+icon_size]
icon_hmac = hmac.new(hmac_key, icon_data, hashlib.sha1)
arm9i = rom[arm9i_offset:arm9i_offset+arm9i_size]
dec_arm9i = bytearray(modcrypt_transform(arm9i[:0x4000], key, iv_area1)) + arm9i[0x4000:]
arm9i_digest = hmac.new(hmac_key, dec_arm9i, hashlib.sha1)
arm7i = rom[arm7i_offset:arm7i_offset+arm7i_size]
arm7i_digest = hmac.new(hmac_key, arm7i, hashlib.sha1)
dec_arm9_hmac = hmac.new(hmac_key, arm9[0x4000:], hashlib.sha1)
if enc_arm9_hmac.digest() == arm9_enc_sha1_hmac:
print("✅ Full arm9 HMAC OK")
else:
print("❌ Full arm9 HMAC fail")
if arm7_hmac.digest() == arm7_sha1_hmac:
print("✅ arm7 HMAC OK")
else:
print("❌ arm7 HMAC fail")
if master_hmac.digest() == master_digest:
print("✅ Master digest OK")
else:
print("❌ Master digest fail")
if icon_hmac.digest() == icon_digest:
print("✅ Icon HMAC OK")
else:
print("❌ Icon HMAC fail")
if arm9i_digest.digest() == arm9i_sha1_hmac:
print("✅ arm9i HMAC OK")
else:
print("❌ arm9i HMAC fail")
if arm7i_digest.digest() == arm7i_sha1_hmac:
print("✅ arm7i HMAC OK")
else:
print("❌ arm7i HMAC fail")
if dec_arm9_hmac.digest() == arm9_dec_sha1_hmac:
print("✅ Plaintext arm9 HMAC OK")
else:
print("❌ Plaintext arm9 HMAC fail")
# Check signature
sig = rom[0xF80:0x1000]
header_entries = rom[:0xE00]
sha1_digest = hashlib.sha1(header_entries).digest()
em_expected = b'\x00' + b'\x01' + b'\xFF' * 105 + b'\x00' + sha1_digest
assert len(em_expected) == 128
# Check if the signature is valid but decrypted (retail)
if sig == em_expected:
print("✅❗ Signature is VALID (decrypted)")
else:
# Get the raw public key and modulus
pub_numbers = public_key.public_numbers()
if is_debug:
n = pub_numbers.n
else:
n = int.from_bytes(retailPublicModulus)
e = pub_numbers.e
# Convert sig to int
sig_int = int.from_bytes(sig, byteorder="big")
decrypted_int = pow(sig_int, e, n)
decrypted = decrypted_int.to_bytes(128, byteorder="big")
if decrypted == em_expected:
print("✅ Signature is VALID")
else:
print("❌ Signature is INVALID")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment