diff --git a/bios/Nintendo/3DS/aes_keys.txt b/bios/Nintendo/3DS/aes_keys.txt index 0b3535e0..2105f15d 100644 --- a/bios/Nintendo/3DS/aes_keys.txt +++ b/bios/Nintendo/3DS/aes_keys.txt @@ -1,3 +1,19 @@ +:AES + +# Generator constant + +generatorConstant=1ff9e9aac5fe0408024591dc5d52768a + +# OTP + +otpKey=06457901d485a367ac4f2ad01c53cf74 +otpIV=ba4f599b0ae1122c80e13f6865c4fa49 + +# Movable + +movableKeyY=a717802dea9776137ba16cd389141cf0 +movableCmacY=6e1e6bcb9ee098dd67ae771ad73b2cc9 + # KeyX slot0x18KeyX=82e9c9bebfb8bdb875ecc0a07d474374 @@ -76,3 +92,24 @@ nfcSecret1Phrase=6c6f636b65642073656372657400 nfcSecret1Seed=fdc8a07694b89e4c47d37de8ce5c74c1 nfcSecret1HmacKey=7f752d2873a20017fef85c0575904b6d nfcIv=4fd39a6e79fceaad99904db8ee38e9db + +:RSA + +# Secure info + +secureInfoExp=010001 +secureInfoMod=b1791a6d1eadd429ba89a1cd433630174bc68730c5e70560197b50d8c4546710a6e8a101bc2ceb0376f005c70ce0b6d6dffd26df33468bdbb2391e7ec01aa1a5a091e807da378676ba390a25429d5961e161d40485a74bb20186beb11a3572c1c2ea28ab7a1015325c9e712b7df965eae6c6fb8baed76c2a94a6c5ece40eaf987e06f20f884fd20635a476e9f70aba5c5b1461520054044593e468270435355aad5809d1193f5a0728d6db6b551f77945dc3be6fae5bcc0863e476dfa29b36ea853403e616eaa905e07f3a3e7e7077cf166a61d17e4d354c744485d4f67b0eee32f1c2d5790248e9621a33baa39b02b02294057ff6b43888e301e55a237c9c0b + +# LFCS + +lfcsExp=010001 +lfcsMod=a3759a3546cfa7fe30ec55a1b64e08e9449d0c72fcd191fd610a288975bce6a9b21556e9c7670255adfc3cee5edb78259a4b221b71e7e9515b2a6793b21868ce5e5e12ffd86806af318d56f9549902346a17e7837496a05aaf6efde6bed686aafd7a65a8ebe11c983a15c17ab540c23d9b7cfdd463c5e6deb77824c629473335b2e937e054ee9fa53dd793ca3eae4db60f5a11e70cdfba03b21e2b31b65906db5f940bf76e74cad4ab55d940058f10fe06050c81bb422190ba4f5c5382e1e10fbc949f60695d1303aae2e0c108424c200b9baa552d55276e24e5d60457588ff75f0cec819f6d2d28f31055f83b7662d4e4a69369b5da6b4023af07eb9cbfa9c9 + +# Ticket wrap + +ticketWrapExp=010001 +ticketWrapMod=d24cb2e48feaf004d4bb08f8f3defcbb0c934a146b15366c9ddc1eb1649b9feb964b569c2283954d3d2b8a1ab21dc1159c2e6cb4cdd4c0bb96dbad4f02d31f45573892af855273aca20c459b9bd3126425c05d766bfd2fad87986c08416aea8d4266cd9d4ffc3f20f7b5672b686793141edde1b11689aca2f6469c9b0ea4577150235185ed4e7e4f2f9036c165a20c73e160c644a47303d2ed9bb0ba2fc90989bd87eb4563d8f7a61d889a7807b155e7f2107d048d828fcba23090839341385614fde4fabe84f2f0532da34750f32ab11fbe084c0083edbf0b50e96a49dd9d1e293e2249ee954db8afd139461ead4f11a2d06836446473140ed3867e4e5ead3b + +:ECC + +rootPublicXY=004e3bb74d5d959e68ce900434fe9e4a3f094a33771fa7c0e4b023264d98014ca1fc799d3fa52171d5f9bd5b1777ec0fef7a38d1669bbf830325843a diff --git a/scripts/crypto_verify.py b/scripts/crypto_verify.py new file mode 100644 index 00000000..58a07c23 --- /dev/null +++ b/scripts/crypto_verify.py @@ -0,0 +1,377 @@ +"""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 is pure Python (no dependencies). +AES decryption requires 'cryptography' library or falls back to openssl CLI. + +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 +import subprocess +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 (with fallback) +# --------------------------------------------------------------------------- + +def _aes_128_cbc_decrypt(data: bytes, key: bytes, iv: bytes) -> bytes: + """Decrypt AES-128-CBC without padding.""" + # Try cryptography library first + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + return decryptor.update(data) + decryptor.finalize() + except ImportError: + pass + + # Try pycryptodome + try: + from Crypto.Cipher import AES # type: ignore[import-untyped] + cipher = AES.new(key, AES.MODE_CBC, iv) + return cipher.decrypt(data) + except ImportError: + pass + + # Fallback to openssl CLI + try: + result = subprocess.run( + [ + "openssl", "enc", "-aes-128-cbc", "-d", + "-K", key.hex(), "-iv", iv.hex(), "-nopad", + ], + input=data, + capture_output=True, + check=True, + ) + return result.stdout + except (subprocess.CalledProcessError, FileNotFoundError): + raise RuntimeError( + "AES decryption requires 'cryptography' or 'pycryptodome' library, " + "or 'openssl' CLI tool" + ) + + +# --------------------------------------------------------------------------- +# 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. + + Source: Azahar src/core/file_sys/otp.cpp + Struct: 0xE0 body + 0x20 SHA256 hash = 0x100 + + ECC certificate verification (sect233r1) is not reproduced — requires + binary field curve arithmetic unavailable in standard Python libraries. + AES decryption + SHA-256 hash is sufficient to prove file integrity. + + Returns (valid, reason_string). + """ + 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(" 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 diff --git a/scripts/verify.py b/scripts/verify.py index 7b918be4..ef5932e2 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -40,6 +40,7 @@ from common import ( load_emulator_profiles, load_platform_config, md5sum, md5_composite, resolve_local_file, resolve_platform_cores, ) +from crypto_verify import check_crypto_validation DEFAULT_DB = "database.json" DEFAULT_PLATFORMS_DIR = "platforms" @@ -205,13 +206,14 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: def check_file_validation( local_path: str, filename: str, validation_index: dict[str, dict], + bios_dir: str = "bios", ) -> str | None: """Check emulator-level validation on a resolved file. - Supports: size (exact/min/max), crc32, md5, sha1, adler32. - Reports but cannot reproduce: signature, crypto (console-specific keys). + Supports: size (exact/min/max), crc32, md5, sha1, adler32, + signature (RSA-2048 PKCS1v15 SHA256), crypto (AES-128-CBC + SHA256). - Returns None if all reproducible checks pass or no validation applies. + Returns None if all checks pass or no validation applies. Returns a reason string if a check fails. """ entry = validation_index.get(filename) @@ -257,9 +259,12 @@ def check_file_validation( f"got 0x{hashes['adler32']}" ) - # Note: signature/crypto checks require console-specific keys and - # cannot be reproduced. Size checks above still apply when combined - # (e.g. validation: [size, signature]). + # Signature/crypto checks (3DS RSA, AES) + if entry["crypto_only"]: + crypto_reason = check_crypto_validation(local_path, filename, bios_dir) + if crypto_reason: + return crypto_reason + return None