From 6bbbd2fec8dff42744fb32ff05e6ac0e064474b0 Mon Sep 17 00:00:00 2001 From: Sam Hadow Date: Thu, 9 Jul 2026 16:25:29 +0200 Subject: [PATCH] cube attack black box --- README.md | 9 +- src/tea3/cli.py | 106 +++++++++++++- src/tea3/cube_attack_blackbox.py | 242 +++++++++++++++++++++++++++++++ src/tea3/utils.py | 17 +++ 4 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 src/tea3/cube_attack_blackbox.py diff --git a/README.md b/README.md index 577d63f..5237d8f 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,11 @@ Modes are: | 5. Variable XOR | XOR chosen bits inside a register and check the evolution during n steps | | 6. F31, F32 analysis | - | | 7. Exhaustive XOR search | XOR bits inside a register, returns the best combination (least number of monomials)| -| 8. Cube attack search | offline part of the cube attack (a precomputed model can be used) | -| 9. Precompute and save model | precompute n steps and save the model | -| 10. Load model | load a saved model | +| 8. Cube attack search (symbolic) | offline part of the cube attack (a precomputed model can be used) | +| 9. Cube attack search (oracle) | offline part of the cube attack (uses TEA-3 cipher as an oracle) | +| 10. Precompute and save model | precompute n steps and save the model | +| 11. Load model | load a saved model | -n steps can be precomputed and then loaded for the offline part of the cube attack with the menu entries 9 and 10. +n steps can be precomputed and then loaded for the offline part of the cube attack with the menu entries 10 and 11. diff --git a/src/tea3/cli.py b/src/tea3/cli.py index 6ee5425..a9b15b1 100644 --- a/src/tea3/cli.py +++ b/src/tea3/cli.py @@ -1,12 +1,13 @@ from tea3.pretty_print import pretty_print from tea3.cliutils import prompt_int, prompt_choice, prompt_list from tea3.tea3model import Tea3Model -from tea3.utils import set_bits +from tea3.utils import set_bits, set_int_bits from tea3.variable_search import run_exhaustive, run_exhaustive_staircase, run_exhaustive_staircase2, run_exhaustive_bp from tea3.sbox import run_sbox from tea3.variable_xor import run_variable_xor, run_exhaustive_xor from tea3.f31f32 import run_f31f32 from tea3.cube_attack import run_cube_attack +from tea3.cube_attack_blackbox import run_cube_attack_offline_tea3 from tea3.precompute import run_precompute_cli, run_load_cli @@ -200,6 +201,94 @@ def run_cube_attack_cli(model = None): print("Done.") +def run_cube_attack_oracle_cli(): + print("\nBlack-box cube attack search on TEA3.") + print("This mode varies frame-number bits and queries the cipher directly.") + print("Frame-number bits are indexed 0–31, with 0 = least significant bit.") + + base_frame_number = prompt_int("Base frame number (0–4294967295): ", 0, 0xFFFFFFFF) + + raw = input("Frame bits to set to 0 (space-separated): ").strip() + fixed_zero_bits = raw.split() if raw else [] + + raw = input("Frame bits to set to 1 (space-separated): ").strip() + fixed_one_bits = raw.split() if raw else [] + + output_byte = prompt_int("Output keystream byte (0–63): ", 0, 63) + output_bit = prompt_int("Output bit within that byte (0–7): ", 0, 7) + + cube_size = prompt_int("Cube size (1–32): ", 1, 32) + + print("\nSearch strategy:") + print(" 1) Exhaustive") + print(" 2) Random sampling") + strategy = prompt_choice("Your choice (1 or 2): ", {1, 2}) + + if strategy == 1: + mode = "exhaustive" + samples = 0 + limit = prompt_int("How many results to keep? (1–100): ", 1, 100) + else: + mode = "random" + samples = prompt_int("How many random cubes to test? (1–200000): ", 1, 200000) + limit = prompt_int("How many results to keep? (1–100): ", 1, 100) + + raw = input( + "Enter 10 key bytes in hex or decimal, separated by spaces " + "(leave empty for all-zero key): " + ).strip() + + if raw: + try: + key_register = [int(x, 0) & 0xFF for x in raw.split()] + except ValueError as e: + print(f"Error: invalid key byte: {e}") + return + if len(key_register) != 10: + print("Error: TEA3 key register must contain exactly 10 bytes.") + return + else: + key_register = [0] * 10 + + try: + fixed_bits = {} + for b in fixed_zero_bits: + fixed_bits[int(b)] = 0 + for b in fixed_one_bits: + fixed_bits[int(b)] = 1 + except ValueError: + print("Error: frame bit indices must be integers.") + return + + public_bits = [i for i in range(32) if i not in fixed_bits] + + if not public_bits: + print("Error: no unfixed frame bits left to use as public variables.") + return + + base_frame_number = set_int_bits(base_frame_number, fixed_zero_bits, 0) + base_frame_number = set_int_bits(base_frame_number, fixed_one_bits, 1) + + print("-" * 50) + + try: + run_cube_attack_offline_tea3( + key_register=key_register, + base_frame_number=base_frame_number, + public_bits=public_bits, + cube_size=cube_size, + mode=mode, + samples=samples, + limit=limit, + output_byte=output_byte, + output_bit=output_bit, + ) + except ValueError as e: + print(f"Error: {e}") + + print("\n" + "=" * 50) + print("Done.") + def main(): model = None @@ -216,14 +305,15 @@ def main(): print(" 5) Variable XOR") print(" 6) F31, F32 analysis") print(" 7) Exhaustive XOR search") - print(" 8) Cube attack search") - print(" 9) Precompute and save model") - print(" 10) Load model") + print(" 8) Cube attack search (symbolic)") + print(" 9) Cube attack search (oracle)") + print(" 10) Precompute and save model") + print(" 11) Load model") print(" 0) Exit") mode = prompt_choice( - "Your choice (0-10): ", - {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + "Your choice (0-11): ", + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} ) if mode == 0: @@ -246,8 +336,10 @@ def main(): elif mode == 8: run_cube_attack_cli(model) elif mode == 9: - run_precompute_cli() + run_cube_attack_oracle_cli() elif mode == 10: + run_precompute_cli() + elif mode == 11: model = run_load_cli() input("\nPress Enter to return to the main menu...") diff --git a/src/tea3/cube_attack_blackbox.py b/src/tea3/cube_attack_blackbox.py new file mode 100644 index 0000000..4a30b2e --- /dev/null +++ b/src/tea3/cube_attack_blackbox.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from dataclasses import dataclass +from itertools import combinations, product +from random import sample +from typing import Sequence + +from tea3.tea3 import Tea3 + +MASK32 = 0xFFFFFFFF + + +@dataclass(frozen=True) +class CubeResult: + cube: tuple[int, ...] + cube_size: int + cube_sum: int + output_byte: int + output_bit: int + + +def get_output_bit( + frame_number: int, + key_register: Sequence[int], + output_byte: int = 0, + output_bit: int = 0, +) -> int: + """ + Evaluate TEA3 as a black box and return one output bit from the keystream. + """ + tea = Tea3(frame_number=frame_number, key_register=key_register) + ks = tea.keystream(output_byte + 1) + return (ks[output_byte] >> output_bit) & 1 + + +def set_bits(value: int, bit_indices: Sequence[int], bits: Sequence[int]) -> int: + """ + Set selected bit positions of `value` according to `bits`. + Bit index 0 is the least significant bit. + """ + if len(bit_indices) != len(bits): + raise ValueError("bit_indices and bits must have the same length") + + x = value & MASK32 + for idx, bit in zip(bit_indices, bits): + if bit not in (0, 1): + raise ValueError("bits must be 0 or 1") + if bit: + x |= 1 << idx + else: + x &= ~(1 << idx) + return x & MASK32 + + +def cube_sum_tea3( + cube_bits: Sequence[int], + base_frame_number: int, + key_register: Sequence[int], + output_byte: int = 0, + output_bit: int = 0, +) -> int: + """ + Compute the cube sum directly on TEA3 by querying the cipher on all assignments of the chosen cube bits. + + The other IV/frame bits are taken from `base_frame_number`. + """ + acc = 0 + for assignment in product((0, 1), repeat=len(cube_bits)): + frame_number = set_bits(base_frame_number, cube_bits, assignment) + acc ^= get_output_bit( + frame_number=frame_number, + key_register=key_register, + output_byte=output_byte, + output_bit=output_bit, + ) + return acc & 1 + + +def search_cubes_exhaustive_tea3( + public_bits: Sequence[int], + cube_size: int, + base_frame_number: int, + key_register: Sequence[int], + output_byte: int = 0, + output_bit: int = 0, + limit: int = 20, + keep_zero: bool = False, +) -> list[CubeResult]: + if cube_size < 0: + raise ValueError("cube_size must be non-negative") + if cube_size > len(public_bits): + raise ValueError("cube_size cannot exceed the number of public bits") + + results: list[CubeResult] = [] + + for cube in combinations(public_bits, cube_size): + s = cube_sum_tea3( + cube_bits=cube, + base_frame_number=base_frame_number, + key_register=key_register, + output_byte=output_byte, + output_bit=output_bit, + ) + if s == 0 and not keep_zero: + continue + + results.append( + CubeResult( + cube=tuple(cube), + cube_size=cube_size, + cube_sum=s, + output_byte=output_byte, + output_bit=output_bit, + ) + ) + + if len(results) >= limit: + break + + return results + + +def search_cubes_random_tea3( + public_bits: Sequence[int], + cube_size: int, + base_frame_number: int, + key_register: Sequence[int], + samples: int = 1000, + output_byte: int = 0, + output_bit: int = 0, + limit: int = 20, + keep_zero: bool = False, +) -> list[CubeResult]: + if cube_size < 0: + raise ValueError("cube_size must be non-negative") + if cube_size > len(public_bits): + raise ValueError("cube_size cannot exceed the number of public bits") + + results: list[CubeResult] = [] + seen: set[tuple[int, ...]] = set() + + idxs = list(range(len(public_bits))) + for _ in range(samples): + cube_idx = tuple(sorted(sample(idxs, cube_size))) + if cube_idx in seen: + continue + seen.add(cube_idx) + + cube = tuple(public_bits[i] for i in cube_idx) + s = cube_sum_tea3( + cube_bits=cube, + base_frame_number=base_frame_number, + key_register=key_register, + output_byte=output_byte, + output_bit=output_bit, + ) + if s == 0 and not keep_zero: + continue + + results.append( + CubeResult( + cube=cube, + cube_size=cube_size, + cube_sum=s, + output_byte=output_byte, + output_bit=output_bit, + ) + ) + + if len(results) >= limit: + break + + return results + + +def run_cube_attack_offline_tea3( + key_register: Sequence[int], + base_frame_number: int, + public_bits: Sequence[int] = tuple(range(32)), + cube_size: int = 4, + mode: str = "random", + samples: int = 2000, + limit: int = 20, + output_byte: int = 0, + output_bit: int = 0, + keep_zero: bool = False, +) -> list[CubeResult]: + """ + Blackbox offline cube search against TEA3. + + This version does not use symbolic polynomials. It evaluates the cipher on all cube assignments and returns cubes whose cube sum is nonzero by default. + + Note: + This is an empirical offline phase. It can identify candidate cubes, but it does not compute the exact superpoly degree. + """ + print("=" * 50) + print("TEA3 black-box cube search") + print(f"Base frame number: 0x{base_frame_number:08x}") + print(f"Public bits: {len(public_bits)}") + print(f"Cube size: {cube_size}") + print(f"Mode: {mode}") + print(f"Output byte/bit: {output_byte}/{output_bit}") + print("=" * 50) + + if mode == "exhaustive": + results = search_cubes_exhaustive_tea3( + public_bits=public_bits, + cube_size=cube_size, + base_frame_number=base_frame_number, + key_register=key_register, + output_byte=output_byte, + output_bit=output_bit, + limit=limit, + keep_zero=keep_zero, + ) + elif mode == "random": + results = search_cubes_random_tea3( + public_bits=public_bits, + cube_size=cube_size, + base_frame_number=base_frame_number, + key_register=key_register, + samples=samples, + output_byte=output_byte, + output_bit=output_bit, + limit=limit, + keep_zero=keep_zero, + ) + else: + raise ValueError("mode must be 'random' or 'exhaustive'") + + if not results: + print("No candidate cubes found.") + return [] + + print(f"Found {len(results)} candidate cube(s):") + for i, res in enumerate(results, 1): + cube_str = " ".join(f"b{b}" for b in res.cube) + print("-" * 50) + print(f"[{i}] cube = {cube_str}") + print(f" cube_sum = {res.cube_sum}") + + return results diff --git a/src/tea3/utils.py b/src/tea3/utils.py index c5ffe9c..edafc55 100644 --- a/src/tea3/utils.py +++ b/src/tea3/utils.py @@ -46,3 +46,20 @@ def set_bits(model, bit_names, value): raise ValueError( f"Invalid bit name '{name}'. Expected R, x, or r." ) + +def set_int_bits(value: int, bit_names, bit_value: int) -> int: + x = value & 0xFFFFFFFF + for raw in bit_names: + name = raw.strip() + if not name: + continue + if not name.isdigit(): + raise ValueError(f"Invalid bit index '{name}'. Expected integers 0–31.") + idx = int(name) + if idx < 0 or idx > 31: + raise ValueError(f"Bit index out of range: {idx}. Expected 0–31.") + if bit_value == 0: + x &= ~(1 << idx) + else: + x |= 1 << idx + return x & 0xFFFFFFFF