From 685713a7e68367710e8c62cfc88145a201ab39f5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:45:16 +0100 Subject: [PATCH] feat: add sect233r1 ECDSA verification for 3DS OTP cert pure python GF(2^233) field arithmetic, binary curve point operations, and ECDSA-SHA256 on sect233r1. verifies OTP CTCert against nintendo root CA public key. zero dependencies. sign+verify round-trip tested, n*G=O verified, wrong key/message rejection confirmed. --- scripts/crypto_verify.py | 86 ++++++++++++- scripts/sect233r1.py | 257 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 scripts/sect233r1.py diff --git a/scripts/crypto_verify.py b/scripts/crypto_verify.py index 58a07c23..b7c8ebb5 100644 --- a/scripts/crypto_verify.py +++ b/scripts/crypto_verify.py @@ -295,17 +295,27 @@ 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. + """Verify otp.bin: AES-128-CBC decrypt + magic + SHA-256 hash + ECC cert. - Source: Azahar src/core/file_sys/otp.cpp + Source: Azahar src/core/file_sys/otp.cpp, src/core/hw/unique_data.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. + 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 ecdsa_verify_sha256, _ec_mul, _Gx, _Gy, _N + data = bytearray(Path(filepath).read_bytes()) if len(data) != 0x100: @@ -336,7 +346,71 @@ def verify_otp( 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)" + # --- ECC certificate verification (sect233r1) --- + ecc_keys = keys.get("ECC", {}) + root_public_xy = ecc_keys.get("rootPublicXY") + if not root_public_xy or len(root_public_xy) != 60: + return True, "decrypted, magic valid, SHA-256 valid (ECC skipped: no rootPublicXY)" + + # Extract CTCert fields from OTP body + device_id = 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" # --------------------------------------------------------------------------- diff --git a/scripts/sect233r1.py b/scripts/sect233r1.py new file mode 100644 index 00000000..f316175c --- /dev/null +++ b/scripts/sect233r1.py @@ -0,0 +1,257 @@ +"""Pure Python ECDSA verification on sect233r1 (binary field curve). + +Implements GF(2^233) field arithmetic, elliptic curve point operations, +and ECDSA-SHA256 verification for Nintendo 3DS OTP certificate checking. + +Zero external dependencies — uses only Python stdlib. + +Curve: sect233r1 (NIST B-233, SEC 2 v2) +Field: GF(2^233) with irreducible polynomial t^233 + t^74 + 1 +Equation: y^2 + xy = x^3 + x^2 + b +""" +from __future__ import annotations + +import hashlib + +# --------------------------------------------------------------------------- +# sect233r1 curve parameters (SEC 2 v2) +# --------------------------------------------------------------------------- + +_M = 233 +_F = (1 << 233) | (1 << 74) | 1 # irreducible polynomial + +_A = 1 +_B = 0x0066647EDE6C332C7F8C0923BB58213B333B20E9CE4281FE115F7D8F90AD + +_Gx = 0x00FAC9DFCBAC8313BB2139F1BB755FEF65BC391F8B36F8F8EB7371FD558B +_Gy = 0x01006A08A41903350678E58528BEBF8A0BEFF867A7CA36716F7E01F81052 + +# Subgroup order +_N = 0x01000000000000000000000000000013E974E72F8A6922031D2603CFE0D7 +_N_BITLEN = _N.bit_length() # 233 + +# Cofactor +_H = 2 + + +# --------------------------------------------------------------------------- +# GF(2^233) field arithmetic +# --------------------------------------------------------------------------- + +def _gf_reduce(a: int) -> int: + """Reduce polynomial a modulo t^233 + t^74 + 1.""" + while a.bit_length() > _M: + shift = a.bit_length() - 1 - _M + a ^= _F << shift + return a + + +def _gf_add(a: int, b: int) -> int: + """Add two elements in GF(2^233). Addition = XOR.""" + return a ^ b + + +def _gf_mul(a: int, b: int) -> int: + """Multiply two elements in GF(2^233).""" + a = _gf_reduce(a) + result = 0 + while b: + if b & 1: + result ^= a + a <<= 1 + b >>= 1 + return _gf_reduce(result) + + +def _gf_sqr(a: int) -> int: + """Square an element in GF(2^233).""" + return _gf_mul(a, a) + + +def _gf_inv(a: int) -> int: + """Multiplicative inverse in GF(2^233) using extended Euclidean algorithm.""" + if a == 0: + raise ZeroDivisionError("inverse of zero in GF(2^m)") + # Extended GCD for polynomials in GF(2)[x] + old_r, r = _F, a + old_s, s = 0, 1 + while r != 0: + # Polynomial division: old_r = q * r + remainder + q = 0 + temp = old_r + dr = r.bit_length() - 1 + while temp != 0 and temp.bit_length() - 1 >= dr: + shift = temp.bit_length() - 1 - dr + q ^= 1 << shift + temp ^= r << shift + remainder = temp + # Multiply q * s in GF(2)[x] (no reduction — working in polynomial ring) + qs = 0 + qt = q + st = s + while qt: + if qt & 1: + qs ^= st + st <<= 1 + qt >>= 1 + old_r, r = r, remainder + old_s, s = s, old_s ^ qs + # old_r should be 1 (the GCD) + if old_r != 1: + raise ValueError("element not invertible") + return _gf_reduce(old_s) + + +# --------------------------------------------------------------------------- +# Elliptic curve point operations on sect233r1 +# y^2 + xy = x^3 + ax^2 + b (a=1) +# --------------------------------------------------------------------------- + +# Point at infinity +_INF = None + + +def _ec_add( + p: tuple[int, int] | None, + q: tuple[int, int] | None, +) -> tuple[int, int] | None: + """Add two points on the curve.""" + if p is _INF: + return q + if q is _INF: + return p + + x1, y1 = p + x2, y2 = q + + if x1 == x2: + if y1 == _gf_add(y2, x2): + # P + (-P) = O + return _INF + if y1 == y2: + # P == Q, use doubling + return _ec_double(p) + return _INF + + # lambda = (y1 + y2) / (x1 + x2) + lam = _gf_mul(_gf_add(y1, y2), _gf_inv(_gf_add(x1, x2))) + # x3 = lambda^2 + lambda + x1 + x2 + a + x3 = _gf_add(_gf_add(_gf_add(_gf_sqr(lam), lam), _gf_add(x1, x2)), _A) + # y3 = lambda * (x1 + x3) + x3 + y1 + y3 = _gf_add(_gf_add(_gf_mul(lam, _gf_add(x1, x3)), x3), y1) + return (x3, y3) + + +def _ec_double(p: tuple[int, int] | None) -> tuple[int, int] | None: + """Double a point on the curve.""" + if p is _INF: + return _INF + + x1, y1 = p + if x1 == 0: + return _INF + + # lambda = x1 + y1/x1 + lam = _gf_add(x1, _gf_mul(y1, _gf_inv(x1))) + # x3 = lambda^2 + lambda + a + x3 = _gf_add(_gf_add(_gf_sqr(lam), lam), _A) + # y3 = x1^2 + (lambda + 1) * x3 + y3 = _gf_add(_gf_sqr(x1), _gf_mul(_gf_add(lam, 1), x3)) + return (x3, y3) + + +def _ec_mul(k: int, p: tuple[int, int] | None) -> tuple[int, int] | None: + """Scalar multiplication k*P using double-and-add.""" + if k == 0 or p is _INF: + return _INF + + result = _INF + addend = p + while k: + if k & 1: + result = _ec_add(result, addend) + addend = _ec_double(addend) + k >>= 1 + return result + + +# --------------------------------------------------------------------------- +# ECDSA-SHA256 verification +# --------------------------------------------------------------------------- + +def _modinv(a: int, m: int) -> int: + """Modular inverse of a modulo m (integers, not GF(2^m)).""" + if a < 0: + a = a % m + g, x, _ = _extended_gcd(a, m) + if g != 1: + raise ValueError("modular inverse does not exist") + return x % m + + +def _extended_gcd(a: int, b: int) -> tuple[int, int, int]: + """Extended Euclidean algorithm for integers.""" + if a == 0: + return b, 0, 1 + g, x, y = _extended_gcd(b % a, a) + return g, y - (b // a) * x, x + + +def ecdsa_verify_sha256( + message: bytes, + signature_rs: bytes, + public_key_xy: bytes, +) -> bool: + """Verify ECDSA-SHA256 signature on sect233r1. + + Args: + message: The data that was signed. + signature_rs: 60 bytes (r || s, each 30 bytes big-endian). + public_key_xy: 60 bytes (x || y, each 30 bytes big-endian). + + Returns: + True if the signature is valid. + """ + if len(signature_rs) != 60: + return False + if len(public_key_xy) != 60: + return False + + # Parse signature + r = int.from_bytes(signature_rs[:30], "big") + s = int.from_bytes(signature_rs[30:], "big") + + # Parse public key + qx = int.from_bytes(public_key_xy[:30], "big") + qy = int.from_bytes(public_key_xy[30:], "big") + q_point = (qx, qy) + + # Check r, s in [1, n-1] + if not (1 <= r < _N and 1 <= s < _N): + return False + + # Compute hash + h = hashlib.sha256(message).digest() + e = int.from_bytes(h, "big") + # Truncate to bit length of n + if 256 > _N_BITLEN: + e >>= 256 - _N_BITLEN + + # Compute w = s^(-1) mod n + w = _modinv(s, _N) + + # Compute u1 = e*w mod n, u2 = r*w mod n + u1 = (e * w) % _N + u2 = (r * w) % _N + + # Compute R = u1*G + u2*Q + g_point = (_Gx, _Gy) + r_point = _ec_add(_ec_mul(u1, g_point), _ec_mul(u2, q_point)) + + if r_point is _INF: + return False + + # v = R.x (as integer, already in GF(2^m) which is an integer) + v = r_point[0] % _N + + return v == r