Files
libretro/tests/test_crypto_verify.py
Abdessamad Derraz 0b5c534af4 fix: settle a contributed path before reading it
validate_pr.py inspects paths chosen by whoever opened the pull
request, and it hashed the file before deciding whether it was a
symlink. A link to /dev/zero was read until the job timed out, and a
link out of the checkout was hashed and reported as though its target
had been contributed. The shape is now settled first, and a test that
used to hang the run covers it.

The gate had no tests at all, and neither did the 3DS crypto reached
by dynamic import from validation.py: RSA PKCS#1 v1.5, AES-128-CBC and
ECDSA over GF(2^233), all written by hand. Coverage goes from 0 to 95%
on the curve, 0 to 55% on the gate, 9 to 35% on the rest. The curve
tests check against the published SEC 2 parameters rather than against
the module: the generator satisfies the curve equation and the group
order takes it to infinity.
2026-08-11 01:39:29 +02:00

334 lines
12 KiB
Python

#!/usr/bin/env python3
"""The hand-written crypto that backs 3DS file validation.
crypto_verify.py and sect233r1.py reimplement RSA PKCS#1 v1.5, AES-128-CBC
and ECDSA over GF(2^233) in pure Python, and validation.py reaches them by a
dynamic import. Nothing exercised them, and a signature check that accepts
everything looks exactly like one that works.
Where possible the expected values come from outside the modules: the curve
parameters are the published SEC 2 ones, so "G is on the curve" and
"n*G is the point at infinity" test the field arithmetic against real
constants rather than against itself. The AES vector is from NIST SP 800-38A.
"""
from __future__ import annotations
import hashlib
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import crypto_verify # noqa: E402
import sect233r1 as ec # noqa: E402
class FieldArithmetic(unittest.TestCase):
"""GF(2^233) with the reduction polynomial t^233 + t^74 + 1."""
VALUES = (1, 2, 3, 0x1234567, ec._Gx, ec._Gy, (1 << 232) | 1)
def test_reduction_keeps_the_degree_below_the_field_size(self):
for value in (1 << 300, (1 << 466) - 1, ec._F, ec._F << 5):
self.assertLessEqual(ec._gf_reduce(value).bit_length(), ec._M)
def test_addition_is_xor_and_is_its_own_inverse(self):
for a in self.VALUES:
for b in self.VALUES:
self.assertEqual(ec._gf_add(a, b), a ^ b)
self.assertEqual(ec._gf_add(ec._gf_add(a, b), b), a)
def test_multiplication_commutes_and_has_one_as_identity(self):
for a in self.VALUES:
self.assertEqual(ec._gf_mul(a, 1), ec._gf_reduce(a))
for b in self.VALUES:
self.assertEqual(ec._gf_mul(a, b), ec._gf_mul(b, a))
def test_multiplication_is_associative(self):
a, b, c = ec._Gx, ec._Gy, 0x1234567
self.assertEqual(
ec._gf_mul(ec._gf_mul(a, b), c), ec._gf_mul(a, ec._gf_mul(b, c))
)
def test_squaring_agrees_with_multiplying_by_self(self):
for a in self.VALUES:
self.assertEqual(ec._gf_sqr(a), ec._gf_mul(a, a))
def test_inverse_multiplied_back_gives_one(self):
for a in self.VALUES:
self.assertEqual(ec._gf_mul(a, ec._gf_inv(a)), 1)
def test_zero_has_no_inverse(self):
with self.assertRaises(ZeroDivisionError):
ec._gf_inv(0)
class CurveParameters(unittest.TestCase):
"""Checks against the published SEC 2 v2 values, not against the module."""
def _on_curve(self, point) -> bool:
x, y = point
left = ec._gf_add(ec._gf_sqr(y), ec._gf_mul(x, y))
right = ec._gf_add(
ec._gf_add(ec._gf_mul(ec._gf_sqr(x), x), ec._gf_sqr(x)), ec._B
)
return left == right
def test_the_generator_satisfies_the_curve_equation(self):
self.assertTrue(self._on_curve((ec._Gx, ec._Gy)))
def test_multiplying_the_generator_by_the_group_order_gives_infinity(self):
"""The definitive check on the point arithmetic."""
self.assertIsNone(ec._ec_mul(ec._N, (ec._Gx, ec._Gy)))
def test_scalar_multiplication_agrees_with_repeated_addition(self):
g = (ec._Gx, ec._Gy)
accumulated = None
for k in range(1, 9):
accumulated = ec._ec_add(accumulated, g)
self.assertEqual(ec._ec_mul(k, g), accumulated, f"k={k}")
def test_doubling_agrees_with_adding_a_point_to_itself(self):
g = (ec._Gx, ec._Gy)
self.assertEqual(ec._ec_double(g), ec._ec_add(g, g))
def test_infinity_is_the_additive_identity(self):
g = (ec._Gx, ec._Gy)
self.assertEqual(ec._ec_add(g, None), g)
self.assertEqual(ec._ec_add(None, g), g)
self.assertIsNone(ec._ec_add(None, None))
self.assertIsNone(ec._ec_mul(0, g))
def test_modular_inverse_round_trips(self):
for a in (1, 2, 3, 12345, ec._N - 1):
self.assertEqual((a * ec._modinv(a, ec._N)) % ec._N, 1)
def _sign(message: bytes, private: int, nonce: int) -> bytes:
"""ECDSA-SHA256 signing, so verification has something real to check."""
g = (ec._Gx, ec._Gy)
point = ec._ec_mul(nonce, g)
r = point[0] % ec._N
digest = int.from_bytes(hashlib.sha256(message).digest(), "big")
if 256 > ec._N_BITLEN:
digest >>= 256 - ec._N_BITLEN
s = (ec._modinv(nonce, ec._N) * (digest + r * private)) % ec._N
return r.to_bytes(30, "big") + s.to_bytes(30, "big")
class EcdsaVerification(unittest.TestCase):
PRIVATE = 0x0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01234567
NONCE = 0x00FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA
MESSAGE = b"otp certificate body"
@classmethod
def setUpClass(cls):
point = ec._ec_mul(cls.PRIVATE, (ec._Gx, ec._Gy))
cls.public = point[0].to_bytes(30, "big") + point[1].to_bytes(30, "big")
cls.signature = _sign(cls.MESSAGE, cls.PRIVATE, cls.NONCE)
def test_a_valid_signature_verifies(self):
self.assertTrue(
ec.ecdsa_verify_sha256(self.MESSAGE, self.signature, self.public)
)
def test_a_changed_message_is_rejected(self):
self.assertFalse(
ec.ecdsa_verify_sha256(b"otp certificate bodY", self.signature, self.public)
)
def test_a_flipped_signature_bit_is_rejected(self):
tampered = bytearray(self.signature)
tampered[45] ^= 0x01
self.assertFalse(
ec.ecdsa_verify_sha256(self.MESSAGE, bytes(tampered), self.public)
)
def test_another_public_key_is_rejected(self):
other = ec._ec_mul(self.PRIVATE + 1, (ec._Gx, ec._Gy))
encoded = other[0].to_bytes(30, "big") + other[1].to_bytes(30, "big")
self.assertFalse(
ec.ecdsa_verify_sha256(self.MESSAGE, self.signature, encoded)
)
def test_wrong_length_inputs_are_rejected(self):
self.assertFalse(ec.ecdsa_verify_sha256(self.MESSAGE, b"\x00" * 59, self.public))
self.assertFalse(
ec.ecdsa_verify_sha256(self.MESSAGE, self.signature, b"\x00" * 61)
)
def test_out_of_range_scalars_are_rejected(self):
for bad in (b"\x00" * 30, ec._N.to_bytes(30, "big")):
self.assertFalse(
ec.ecdsa_verify_sha256(self.MESSAGE, bad + self.signature[30:], self.public)
)
self.assertFalse(
ec.ecdsa_verify_sha256(self.MESSAGE, self.signature[:30] + bad, self.public)
)
# A 512-bit RSA key built from two fixed primes. Small enough to keep the test
# instant, and the padding logic under test does not depend on modulus size.
_P = 0xE2E9E9B5C1E1C4A1D0A1F7B3C5D7E9F1A3B5C7D9E1F3A5B7C9D1E3F5A7B9C223
_Q = 0xC5D7E9F1A3B5C7D9E1F3A5B7C9D1E3F5A7B9C1D3E5F7A9B1C3D5E7F9A1B3C5DD
class RsaPkcs1V15(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.n = _P * _Q
cls.e = 65537
cls.d = pow(cls.e, -1, (_P - 1) * (_Q - 1))
cls.mod_len = (cls.n.bit_length() + 7) // 8
cls.modulus = cls.n.to_bytes(cls.mod_len, "big")
cls.exponent = cls.e.to_bytes(3, "big")
def _sign(self, message: bytes) -> bytes:
prefix = bytes.fromhex("3031300d060960864801650304020105000420")
digest_info = prefix + hashlib.sha256(message).digest()
ps_len = self.mod_len - len(digest_info) - 3
em = b"\x00\x01" + b"\xff" * ps_len + b"\x00" + digest_info
return pow(int.from_bytes(em, "big"), self.d, self.n).to_bytes(
self.mod_len, "big"
)
def test_a_valid_signature_verifies(self):
message = b"SecureInfo_A body"
self.assertTrue(
crypto_verify._rsa_verify_pkcs1v15_sha256(
message, self._sign(message), self.modulus, self.exponent
)
)
def test_a_changed_message_is_rejected(self):
signature = self._sign(b"SecureInfo_A body")
self.assertFalse(
crypto_verify._rsa_verify_pkcs1v15_sha256(
b"SecureInfo_B body", signature, self.modulus, self.exponent
)
)
def test_a_flipped_signature_bit_is_rejected(self):
tampered = bytearray(self._sign(b"body"))
tampered[-1] ^= 0x01
self.assertFalse(
crypto_verify._rsa_verify_pkcs1v15_sha256(
b"body", bytes(tampered), self.modulus, self.exponent
)
)
def test_a_signature_at_or_above_the_modulus_is_rejected(self):
self.assertFalse(
crypto_verify._rsa_verify_pkcs1v15_sha256(
b"body", self.modulus, self.modulus, self.exponent
)
)
def test_an_all_zero_signature_is_rejected(self):
self.assertFalse(
crypto_verify._rsa_verify_pkcs1v15_sha256(
b"body", b"\x00" * self.mod_len, self.modulus, self.exponent
)
)
def test_padding_shorter_than_eight_bytes_is_rejected(self):
"""A modulus too small to hold the required padding cannot verify."""
tiny = (1 << 335).to_bytes(42, "big")
self.assertFalse(
crypto_verify._rsa_verify_pkcs1v15_sha256(
b"body", b"\x01" * 42, tiny, self.exponent
)
)
@unittest.skipUnless(
_HAS_AES := (
shutil.which("openssl") is not None
or __import__("importlib").util.find_spec("cryptography") is not None
),
"needs openssl or the cryptography package",
)
class AesCbc(unittest.TestCase):
"""NIST SP 800-38A F.2.2 AES-128-CBC vector."""
KEY = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
IV = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
CIPHERTEXT = bytes.fromhex("7649abac8119b246cee98e9b12e9197d")
PLAINTEXT = bytes.fromhex("6bc1bee22e409f96e93d7e117393172a")
def test_the_published_vector_decrypts(self):
self.assertEqual(
crypto_verify._aes_128_cbc_decrypt(self.CIPHERTEXT, self.KEY, self.IV),
self.PLAINTEXT,
)
def test_a_different_key_does_not_give_the_plaintext(self):
other = bytes(self.KEY[:-1]) + bytes([self.KEY[-1] ^ 0x01])
self.assertNotEqual(
crypto_verify._aes_128_cbc_decrypt(self.CIPHERTEXT, other, self.IV),
self.PLAINTEXT,
)
class KeysFile(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_sections_comments_and_hex_values_are_parsed(self):
path = self.tmp / "aes_keys.txt"
path.write_text(
"# a comment\n"
"\n"
":AES\n"
"slot0x2CKeyN=00112233445566778899aabbccddeeff\n"
" slot0x3DKeyX = ffeeddccbbaa99887766554433221100 \n"
":RSA\n"
"mod=0102\n"
"not a pair\n"
"odd_length=abc\n"
)
parsed = crypto_verify.parse_keys_file(path)
self.assertEqual(
parsed["AES"]["slot0x2CKeyN"],
bytes.fromhex("00112233445566778899aabbccddeeff"),
)
self.assertEqual(parsed["AES"]["slot0x3DKeyX"][:2], b"\xff\xee")
self.assertEqual(parsed["RSA"]["mod"], b"\x01\x02")
self.assertNotIn("odd_length", parsed["RSA"])
self.assertNotIn("not a pair", parsed["RSA"])
def test_an_empty_file_yields_no_sections(self):
path = self.tmp / "aes_keys.txt"
path.write_text("")
self.assertEqual(crypto_verify.parse_keys_file(path), {})
def test_the_keys_file_is_found_under_nintendo_3ds(self):
target = self.tmp / "Nintendo" / "3DS"
target.mkdir(parents=True)
(target / "aes_keys.txt").write_text(":AES\n")
self.assertEqual(
crypto_verify.find_keys_file(self.tmp), target / "aes_keys.txt"
)
def test_the_alternate_name_is_found(self):
target = self.tmp / "Nintendo" / "3DS"
target.mkdir(parents=True)
(target / "keys.txt").write_text(":AES\n")
self.assertEqual(crypto_verify.find_keys_file(self.tmp), target / "keys.txt")
def test_absence_reports_none(self):
self.assertIsNone(crypto_verify.find_keys_file(self.tmp))
if __name__ == "__main__":
unittest.main()