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.
This commit is contained in:
Abdessamad Derraz
2026-03-24 11:45:16 +01:00
parent ef25f8cebf
commit 685713a7e6
2 changed files with 337 additions and 6 deletions

View File

@@ -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, 0x04)[0]
otp_version = data[0x18]
system_type = data[0x19]
# Expiry date endianness depends on otp_version
if otp_version < 5:
expiry_date = struct.unpack_from(">I", data, 0x20)[0]
else:
expiry_date = struct.unpack_from("<I", data, 0x20)[0]
# ECC private key (0x20 bytes at offset 0x24)
priv_key_raw = bytes(data[0x24:0x44])
# ECC signature (0x3C bytes at offset 0x44)
signature_rs = bytes(data[0x44:0x80])
# Fix up private key: privkey % subgroup_order (Nintendo's ECC lib quirk)
priv_key_int = int.from_bytes(priv_key_raw, "big") % _N
# Derive public key from private key: Q = privkey * G
pub_point = _ec_mul(priv_key_int, (_Gx, _Gy))
if pub_point is None:
return False, "ECC cert: derived public key is point at infinity"
pub_key_xy = (
pub_point[0].to_bytes(30, "big") + pub_point[1].to_bytes(30, "big")
)
# Build certificate body (what was signed)
# Issuer: "Nintendo CA - G3_NintendoCTR2prod" or "...dev"
issuer = bytearray(0x40)
if system_type == 0:
issuer_str = b"Nintendo CA - G3_NintendoCTR2prod"
else:
issuer_str = b"Nintendo CA - G3_NintendoCTR2dev"
issuer[:len(issuer_str)] = issuer_str
# Name: "CT{device_id:08X}-{system_type:02X}"
name = bytearray(0x40)
name_str = f"CT{device_id:08X}-{system_type:02X}".encode()
name[:len(name_str)] = name_str
# Key type = 2 (ECC), big-endian u32
key_type = struct.pack(">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"
# ---------------------------------------------------------------------------

257
scripts/sect233r1.py Normal file
View File

@@ -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