fix: decrypt otp.bin with the standard library

This commit is contained in:
Abdessamad Derraz committed 2026-10-06 01:41:27 +02:00
1 parent fdc7fc8326
commit d257ecc6ab
2 files changed
+139 -47

No files matched your search

+85 -47
View File
@@ -6,8 +6,7 @@ Reproduces the exact verification logic from Azahar/Citra source code:
- movable.sed: magic check + RSA on embedded LFCS - movable.sed: magic check + RSA on embedded LFCS
- otp.bin: AES-128-CBC decrypt + magic + SHA256 hash - otp.bin: AES-128-CBC decrypt + magic + SHA256 hash
RSA verification is pure Python (no dependencies). RSA verification and AES decryption are pure Python (no dependencies).
AES decryption requires 'cryptography' library or falls back to openssl CLI.
Source refs: Source refs:
Azahar src/core/hw/unique_data.cpp Azahar src/core/hw/unique_data.cpp
@@ -19,7 +18,6 @@ from __future__ import annotations
import hashlib import hashlib
import struct import struct
import subprocess
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
@@ -137,54 +135,94 @@ def _rsa_verify_pkcs1v15_sha256(
return em == expected_em return em == expected_em
# AES-128-CBC decryption (with fallback) # AES-128-CBC decryption, standard library only. The build promises stdlib +
# pyyaml; a contributor without cryptography, pycryptodome or openssl on PATH
# got a RuntimeError from verify.py on otp.bin. 256 bytes need no speed.
def _xtime(value: int) -> int:
value <<= 1
return (value ^ 0x11B) & 0xFF if value & 0x100 else value
def _gmul(a: int, b: int) -> int:
product = 0
while b:
if b & 1:
product ^= a
a = _xtime(a)
b >>= 1
return product
def _build_sbox() -> tuple[list[int], list[int]]:
sbox = [0] * 256
inverse = [0] * 256
for value in range(256):
# Multiplicative inverse in GF(2^8), then the affine transform.
inv = 0 if value == 0 else next(
c for c in range(1, 256) if _gmul(value, c) == 1
)
out = inv
for shift in range(1, 5):
out ^= ((inv << shift) | (inv >> (8 - shift))) & 0xFF
out ^= 0x63
sbox[value] = out
inverse[out] = value
return sbox, inverse
_SBOX, _INV_SBOX = _build_sbox()
def _expand_key(key: bytes) -> list[list[int]]:
"""The eleven round keys of AES-128, each sixteen bytes."""
words = [list(key[i : i + 4]) for i in range(0, 16, 4)]
rcon = 1
for i in range(4, 44):
word = list(words[i - 1])
if i % 4 == 0:
word = [_SBOX[b] for b in word[1:] + word[:1]]
word[0] ^= rcon
rcon = _xtime(rcon)
words.append([a ^ b for a, b in zip(words[i - 4], word)])
return [sum(words[r * 4 : r * 4 + 4], []) for r in range(11)]
def _decrypt_block(block: bytes, round_keys: list[list[int]]) -> bytes:
state = [b ^ k for b, k in zip(block, round_keys[10])]
for rnd in range(9, -1, -1):
# Inverse ShiftRows on a column-major state, then inverse SubBytes.
state = [state[(i + 4 * (i % 4) * 3) % 16] for i in range(16)]
state = [_INV_SBOX[b] for b in state]
state = [b ^ k for b, k in zip(state, round_keys[rnd])]
if rnd:
mixed = []
for c in range(4):
a0, a1, a2, a3 = state[c * 4 : c * 4 + 4]
mixed += [
_gmul(a0, 14) ^ _gmul(a1, 11) ^ _gmul(a2, 13) ^ _gmul(a3, 9),
_gmul(a0, 9) ^ _gmul(a1, 14) ^ _gmul(a2, 11) ^ _gmul(a3, 13),
_gmul(a0, 13) ^ _gmul(a1, 9) ^ _gmul(a2, 14) ^ _gmul(a3, 11),
_gmul(a0, 11) ^ _gmul(a1, 13) ^ _gmul(a2, 9) ^ _gmul(a3, 14),
]
state = mixed
return bytes(state)
def _aes_128_cbc_decrypt(data: bytes, key: bytes, iv: bytes) -> bytes: def _aes_128_cbc_decrypt(data: bytes, key: bytes, iv: bytes) -> bytes:
"""Decrypt AES-128-CBC without padding.""" """Decrypt AES-128-CBC without padding."""
# Try cryptography library first if len(key) != 16 or len(iv) != 16 or len(data) % 16:
try: raise ValueError("AES-128-CBC needs a 16-byte key and IV and whole blocks")
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes round_keys = _expand_key(key)
out = bytearray()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) previous = iv
decryptor = cipher.decryptor() for offset in range(0, len(data), 16):
return decryptor.update(data) + decryptor.finalize() block = data[offset : offset + 16]
except ImportError: plain = _decrypt_block(block, round_keys)
pass out += bytes(a ^ b for a, b in zip(plain, previous))
previous = block
# Try pycryptodome return bytes(out)
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 # File verification functions
+54
View File
@@ -0,0 +1,54 @@
"""otp.bin decrypts with the standard library alone.
The build promises stdlib + pyyaml. AES needed cryptography, pycryptodome
or an openssl binary, and verify.py raised RuntimeError on otp.bin for a
contributor with none of them.
"""
from __future__ import annotations
import ast
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import crypto_verify # noqa: E402
STDLIB_OR_LOCAL = {"__future__", "hashlib", "struct", "collections", "pathlib", "sect233r1"}
class StdlibAes(unittest.TestCase):
def test_fips_197_vector(self):
key = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
cipher = bytes.fromhex("69c4e0d86a7b0430d8cdb78070b4c55a")
plain = crypto_verify._decrypt_block(cipher, crypto_verify._expand_key(key))
self.assertEqual(plain.hex(), "00112233445566778899aabbccddeeff")
def test_cbc_chains_blocks(self):
# NIST SP 800-38A F.2.2, CBC-AES128 decrypt, first two blocks.
key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
iv = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
cipher = bytes.fromhex(
"7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2"
)
self.assertEqual(
crypto_verify._aes_128_cbc_decrypt(cipher, key, iv).hex(),
"6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51",
)
def test_no_third_party_import(self):
tree = ast.parse((REPO_ROOT / "scripts" / "crypto_verify.py").read_text())
modules = {
(node.module if isinstance(node, ast.ImportFrom) else alias.name).split(".")[0]
for node in ast.walk(tree)
if isinstance(node, (ast.Import, ast.ImportFrom))
for alias in node.names
}
self.assertLessEqual(modules, STDLIB_OR_LOCAL)
if __name__ == "__main__":
unittest.main()