"""3DS signature and crypto verification for emulator profile validation. Reproduces the exact verification logic from Azahar/Citra source code: - SecureInfo_A: RSA-2048 PKCS1v15 SHA256 - LocalFriendCodeSeed_B: RSA-2048 PKCS1v15 SHA256 - movable.sed: magic check + RSA on embedded LFCS - otp.bin: AES-128-CBC decrypt + magic + SHA256 hash RSA verification and AES decryption are pure Python (no dependencies). Source refs: Azahar src/core/hw/unique_data.cpp Azahar src/core/hw/rsa/rsa.cpp Azahar src/core/file_sys/otp.cpp """ from __future__ import annotations import hashlib import struct from collections.abc import Callable from pathlib import Path # Key file parsing (keys.txt / aes_keys.txt format) def parse_keys_file(path: str | Path) -> dict[str, dict[str, bytes]]: """Parse a 3DS keys file with :AES, :RSA, :ECC sections. Returns {section: {key_name: bytes_value}}. """ sections: dict[str, dict[str, bytes]] = {} current_section = "" for line in Path(path).read_text(errors="replace").splitlines(): line = line.strip() if not line or line.startswith("#"): continue if line.startswith(":"): current_section = line[1:].strip() if current_section not in sections: sections[current_section] = {} continue if "=" not in line: continue key, _, value = line.partition("=") key = key.strip() value = value.strip() try: sections.setdefault(current_section, {})[key] = bytes.fromhex(value) except ValueError: continue return sections def find_keys_file(bios_dir: str | Path) -> Path | None: """Find the 3DS keys file in the bios directory.""" candidates = [ Path(bios_dir) / "Nintendo" / "3DS" / "aes_keys.txt", Path(bios_dir) / "Nintendo" / "3DS" / "keys.txt", ] for p in candidates: if p.exists(): return p return None # Pure Python RSA-2048 PKCS1v15 SHA256 verification (zero dependencies) def _rsa_verify_pkcs1v15_sha256( message: bytes, signature: bytes, modulus: bytes, exponent: bytes, ) -> bool: """Verify RSA-2048 PKCS#1 v1.5 with SHA-256. Pure Python -uses Python's native int for modular exponentiation. Reproduces CryptoPP::RSASS::Verifier. """ n = int.from_bytes(modulus, "big") e = int.from_bytes(exponent, "big") s = int.from_bytes(signature, "big") if s >= n: return False # RSA verification: m = s^e mod n m = pow(s, e, n) # Convert to bytes, padded to modulus length mod_len = len(modulus) try: em = m.to_bytes(mod_len, "big") except OverflowError: return False # PKCS#1 v1.5 signature encoding: 0x00 0x01 [0xFF padding] 0x00 [DigestInfo] # DigestInfo for SHA-256: # SEQUENCE { SEQUENCE { OID sha256, NULL }, OCTET STRING hash } digest_info_prefix = bytes( [ 0x30, 0x31, # SEQUENCE (49 bytes) 0x30, 0x0D, # SEQUENCE (13 bytes) 0x06, 0x09, # OID (9 bytes) 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, # sha256 0x05, 0x00, # NULL 0x04, 0x20, # OCTET STRING (32 bytes) ] ) sha256_hash = hashlib.sha256(message).digest() expected_digest_info = digest_info_prefix + sha256_hash # Expected encoding: 0x00 0x01 [0xFF * ps_len] 0x00 [digest_info] t_len = len(expected_digest_info) ps_len = mod_len - t_len - 3 if ps_len < 8: return False expected_em = b"\x00\x01" + (b"\xff" * ps_len) + b"\x00" + expected_digest_info return em == expected_em # AES-128-CBC decryption, standard library only. The build promises stdlib + # pyyaml; a contributor without cryptography, pycryptodome or openssl on PATH # got a RuntimeError from verify.py on otp.bin. 256 bytes need no speed. def _xtime(value: int) -> int: value <<= 1 return (value ^ 0x11B) & 0xFF if value & 0x100 else value def _gmul(a: int, b: int) -> int: product = 0 while b: if b & 1: product ^= a a = _xtime(a) b >>= 1 return product def _build_sbox() -> tuple[list[int], list[int]]: sbox = [0] * 256 inverse = [0] * 256 for value in range(256): # Multiplicative inverse in GF(2^8), then the affine transform. inv = 0 if value == 0 else next( c for c in range(1, 256) if _gmul(value, c) == 1 ) out = inv for shift in range(1, 5): out ^= ((inv << shift) | (inv >> (8 - shift))) & 0xFF out ^= 0x63 sbox[value] = out inverse[out] = value return sbox, inverse _SBOX, _INV_SBOX = _build_sbox() def _expand_key(key: bytes) -> list[list[int]]: """The eleven round keys of AES-128, each sixteen bytes.""" words = [list(key[i : i + 4]) for i in range(0, 16, 4)] rcon = 1 for i in range(4, 44): word = list(words[i - 1]) if i % 4 == 0: word = [_SBOX[b] for b in word[1:] + word[:1]] word[0] ^= rcon rcon = _xtime(rcon) words.append([a ^ b for a, b in zip(words[i - 4], word)]) return [sum(words[r * 4 : r * 4 + 4], []) for r in range(11)] def _decrypt_block(block: bytes, round_keys: list[list[int]]) -> bytes: state = [b ^ k for b, k in zip(block, round_keys[10])] for rnd in range(9, -1, -1): # Inverse ShiftRows on a column-major state, then inverse SubBytes. state = [state[(i + 4 * (i % 4) * 3) % 16] for i in range(16)] state = [_INV_SBOX[b] for b in state] state = [b ^ k for b, k in zip(state, round_keys[rnd])] if rnd: mixed = [] for c in range(4): a0, a1, a2, a3 = state[c * 4 : c * 4 + 4] mixed += [ _gmul(a0, 14) ^ _gmul(a1, 11) ^ _gmul(a2, 13) ^ _gmul(a3, 9), _gmul(a0, 9) ^ _gmul(a1, 14) ^ _gmul(a2, 11) ^ _gmul(a3, 13), _gmul(a0, 13) ^ _gmul(a1, 9) ^ _gmul(a2, 14) ^ _gmul(a3, 11), _gmul(a0, 11) ^ _gmul(a1, 13) ^ _gmul(a2, 9) ^ _gmul(a3, 14), ] state = mixed return bytes(state) def _aes_128_cbc_decrypt(data: bytes, key: bytes, iv: bytes) -> bytes: """Decrypt AES-128-CBC without padding.""" if len(key) != 16 or len(iv) != 16 or len(data) % 16: raise ValueError("AES-128-CBC needs a 16-byte key and IV and whole blocks") round_keys = _expand_key(key) out = bytearray() previous = iv for offset in range(0, len(data), 16): block = data[offset : offset + 16] plain = _decrypt_block(block, round_keys) out += bytes(a ^ b for a, b in zip(plain, previous)) previous = block return bytes(out) # File verification functions def verify_secure_info_a( filepath: str | Path, keys: dict[str, dict[str, bytes]], ) -> tuple[bool, str]: """Verify SecureInfo_A RSA-2048 PKCS1v15 SHA256 signature. Source: Azahar src/core/hw/unique_data.cpp:43-92 Struct: 0x100 signature + 0x11 body (region + unknown + serial) = 0x111 Returns (valid, reason_string). """ data = Path(filepath).read_bytes() # Size check if len(data) != 0x111: return False, f"size mismatch: expected 273, got {len(data)}" # Check validity (at least one non-zero byte in serial) serial = data[0x102:0x111] if serial == b"\x00" * 15: return False, "invalid: serial_number is all zeros" # Get RSA keys rsa_keys = keys.get("RSA", {}) modulus = rsa_keys.get("secureInfoMod") exponent = rsa_keys.get("secureInfoExp") if not modulus or not exponent: return False, "missing RSA keys (secureInfoMod/secureInfoExp) in keys file" signature = data[0x000:0x100] body = data[0x100:0x111] if _rsa_verify_pkcs1v15_sha256(body, signature, modulus, exponent): return True, "signature valid" # Region change detection: try all other region values region_byte = data[0x100] for test_region in range(7): if test_region == region_byte: continue modified_body = bytes([test_region]) + body[1:] if _rsa_verify_pkcs1v15_sha256(modified_body, signature, modulus, exponent): return ( False, f"signature invalid (region changed from {test_region} to {region_byte})", ) return False, "signature invalid" def verify_local_friend_code_seed_b( filepath: str | Path, keys: dict[str, dict[str, bytes]], ) -> tuple[bool, str]: """Verify LocalFriendCodeSeed_B RSA-2048 PKCS1v15 SHA256 signature. Source: Azahar src/core/hw/unique_data.cpp:94-123 Struct: 0x100 signature + 0x10 body (unknown + friend_code_seed) = 0x110 Returns (valid, reason_string). """ data = Path(filepath).read_bytes() if len(data) != 0x110: return False, f"size mismatch: expected 272, got {len(data)}" # Check validity (friend_code_seed != 0) friend_code_seed = struct.unpack_from(" tuple[bool, str]: """Verify movable.sed: magic check + RSA on embedded LFCS. Source: Azahar src/core/hw/unique_data.cpp:170-200 Struct: 0x08 header + 0x110 embedded LFCS + 0x08 keyY = 0x120 Full variant: 0x120 + 0x20 extra = 0x140 Returns (valid, reason_string). """ data = Path(filepath).read_bytes() if len(data) not in (0x120, 0x140): return False, f"size mismatch: expected 288 or 320, got {len(data)}" # Magic check: "SEED" at offset 0 magic = data[0:4] if magic != b"SEED": return False, f"invalid magic: expected 'SEED', got {magic!r}" # Embedded LFCS at offset 0x08, size 0x110 lfcs_data = data[0x08:0x118] # Verify the embedded LFCS signature (same as LocalFriendCodeSeed_B) rsa_keys = keys.get("RSA", {}) modulus = rsa_keys.get("lfcsMod") exponent = rsa_keys.get("lfcsExp") if not modulus or not exponent: return False, "missing RSA keys (lfcsMod/lfcsExp) in keys file" signature = lfcs_data[0x000:0x100] body = lfcs_data[0x100:0x110] if _rsa_verify_pkcs1v15_sha256(body, signature, modulus, exponent): return True, "magic valid, LFCS signature valid" return False, "magic valid, LFCS signature invalid" def verify_otp( filepath: str | Path, keys: dict[str, dict[str, bytes]], ) -> tuple[bool, str]: """Verify otp.bin: AES-128-CBC decrypt + magic + SHA-256 hash + ECC cert. Source: Azahar src/core/file_sys/otp.cpp, src/core/hw/unique_data.cpp Struct: 0xE0 body + 0x20 SHA256 hash = 0x100 OTP body layout: 0x000: u32 magic (0xDEADB00F) 0x004: u32 device_id 0x018: u8 otp_version 0x019: u8 system_type (0=retail, non-zero=dev) 0x020: u32 ctcert.expiry_date 0x024: 0x20 ctcert.priv_key (ECC private key) 0x044: 0x3C ctcert.signature (ECC signature r||s) Certificate body (what is signed): issuer(0x40) + key_type(4) + name(0x40) + expiration(4) + public_key_xy(0x3C), aligned to 0x100. Returns (valid, reason_string). """ from sect233r1 import _N, _ec_mul, _Gx, _Gy, ecdsa_verify_sha256 data = bytearray(Path(filepath).read_bytes()) if len(data) != 0x100: return False, f"size mismatch: expected 256, got {len(data)}" aes_keys = keys.get("AES", {}) otp_key = aes_keys.get("otpKey") otp_iv = aes_keys.get("otpIV") # Check magic before decryption (file might already be decrypted) magic = struct.unpack_from("I", data, 0x20)[0] else: expiry_date = struct.unpack_from("I", 2) # Expiry date as big-endian u32 expiry_be = struct.pack(">I", expiry_date) # Serialize: issuer + key_type + name + expiry + public_key_xy cert_body = bytes(issuer) + key_type + bytes(name) + expiry_be + pub_key_xy # Align up to 0x40 aligned_size = ((len(cert_body) + 0x3F) // 0x40) * 0x40 cert_body_padded = cert_body.ljust(aligned_size, b"\x00") # Verify ECDSA-SHA256 signature against root public key valid = ecdsa_verify_sha256(cert_body_padded, signature_rs, root_public_xy) if valid: return True, "decrypted, magic valid, SHA-256 valid, ECC cert valid" return False, "decrypted, magic+SHA256 valid, but ECC cert signature invalid" # Unified verification interface for verify.py # Map from (filename, validation_type) to verification function _CRYPTO_VERIFIERS: dict[str, Callable] = { "SecureInfo_A": verify_secure_info_a, "LocalFriendCodeSeed_B": verify_local_friend_code_seed_b, "movable.sed": verify_movable_sed, "otp.bin": verify_otp, } def check_crypto_validation( local_path: str, filename: str, bios_dir: str, ) -> str | None: """Check signature/crypto validation for 3DS files. Returns None if verification passes or is not applicable. Returns a reason string on failure. """ verifier = _CRYPTO_VERIFIERS.get(filename) if not verifier: return None keys_file = find_keys_file(bios_dir) if not keys_file: return "crypto check skipped: no keys file found" keys = parse_keys_file(keys_file) valid, reason = verifier(local_path, keys) if valid: return None return reason