mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-04-13 12:22:33 -05:00
feat: add 3DS signature/crypto verification to verify.py
pure python RSA-2048 PKCS1v15 SHA256 for SecureInfo_A, LocalFriendCodeSeed_B, movable.sed. AES-128-CBC + SHA256 for otp.bin. keys extracted from azahar default_keys.h, added RSA/ECC sections to aes_keys.txt. sect233r1 ECC not reproducible (binary field curve).
This commit is contained in:
@@ -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
|
||||
|
||||
377
scripts/crypto_verify.py
Normal file
377
scripts/crypto_verify.py
Normal file
@@ -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<PKCS1v15, SHA256>::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("<Q", data, 0x108)[0]
|
||||
if friend_code_seed == 0:
|
||||
return False, "invalid: friend_code_seed is zero"
|
||||
|
||||
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 = data[0x000:0x100]
|
||||
body = data[0x100:0x110]
|
||||
|
||||
if _rsa_verify_pkcs1v15_sha256(body, signature, modulus, exponent):
|
||||
return True, "signature valid"
|
||||
return False, "signature invalid"
|
||||
|
||||
|
||||
def verify_movable_sed(
|
||||
filepath: str | Path,
|
||||
keys: dict[str, dict[str, bytes]],
|
||||
) -> 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("<I", data, 0)[0]
|
||||
if magic != 0xDEADB00F:
|
||||
if not otp_key or not otp_iv:
|
||||
return False, "encrypted OTP but missing AES keys (otpKey/otpIV) in keys file"
|
||||
try:
|
||||
data = bytearray(_aes_128_cbc_decrypt(bytes(data), otp_key, otp_iv))
|
||||
except RuntimeError as e:
|
||||
return False, str(e)
|
||||
magic = struct.unpack_from("<I", data, 0)[0]
|
||||
if magic != 0xDEADB00F:
|
||||
return False, f"decryption failed: magic 0x{magic:08X} != 0xDEADB00F"
|
||||
|
||||
# SHA-256 hash verification
|
||||
body = bytes(data[0x00:0xE0])
|
||||
stored_hash = bytes(data[0xE0:0x100])
|
||||
computed_hash = hashlib.sha256(body).digest()
|
||||
|
||||
if computed_hash != stored_hash:
|
||||
return False, "SHA-256 hash mismatch (OTP corrupted)"
|
||||
|
||||
return True, "decrypted, magic valid, SHA-256 valid (ECC cert not verified — sect233r1)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user