#!/usr/bin/python3

# Run me like this:
# $ python3 padding_oracle.py "http://cpsc4200.mpese.com/username/paddingoracle/verify" "5a7793d3..."
# or select "Padding Oracle" from the VS Code debugger

import json
import sys
import time
from typing import Union, Dict, List
from Crypto.Cipher import AES
from Crypto.Hash import SHA256

import requests

# Create one session for each oracle request to share. This allows the
# underlying connection to be re-used, which speeds up subsequent requests!
s = requests.session()

def oracle(url: str, messages: List[bytes]) -> List[Dict[str, str]]:
    while True:
        try:
            r = s.post(url, data={"message": [m.hex() for m in messages]})
            r.raise_for_status()
            return r.json()
        # Under heavy server load, your request might time out. If this happens,
        # the function will automatically retry in 10 seconds for you.
        except requests.exceptions.RequestException as e:
            sys.stderr.write(str(e))
            sys.stderr.write("\nRetrying in 10 seconds...\n")
            time.sleep(10)
            continue
        except json.JSONDecodeError as e:
            sys.stderr.write("It's possible that the oracle server is overloaded right now, or that provided URL is wrong.\n")
            sys.stderr.write("If this keeps happening, check the URL. Perhaps your uniqname is not set.\n")
            sys.stderr.write("Retrying in 10 seconds...\n\n")
            time.sleep(10)
            continue

def main():
    if len(sys.argv) != 3:
        print(f"usage: {sys.argv[0]} ORACLE_URL CIPHERTEXT_HEX", file=sys.stderr)
        sys.exit(-1)
    oracle_url, message = sys.argv[1], bytes.fromhex(sys.argv[2])

    if oracle(oracle_url, [message])[0]["status"] != "valid":
        print("Message invalid", file=sys.stderr)

    # Split off the first block of ciphertext, which is the initialization 
    # vector
    iv = [message[i : i + AES.block_size] for i in range(0, AES.block_size)][0]
    # Split the remaining ciphertext into blocks of 16 bytes
    ciphertextBlocks = [message[i : i + AES.block_size] for i in range(AES.block_size, len(message), AES.block_size)]
    
    # Create an array to store decrypted plaintext blocks
    plaintextBlocks = []
    
    # Loop through ciphertext blocks starting from last block
    for blockIndex in reversed(range(len(ciphertextBlocks))):
        
        # Store the current ciphertext block
        c2 = bytearray(ciphertextBlocks[blockIndex])
        
        # Create a placeholder variable for the previous ciphertext block
        c1 = None
        # If blockIndex is 0, that means the "previous" block is actually 
        # the initialization vector
        if blockIndex == 0:
            
            # Store the initialization vector
            c1 = bytearray(iv)
            
        # Otherwise
        else:
            
            # Store the previous ciphertext block
            c1 = bytearray(ciphertextBlocks[blockIndex - 1])
        
        # Create a block-sized array full of zeros that will be filled with 
        # guessed byte values
        c1Prime = bytearray(AES.block_size)
        
        # Create an array to store the calculated intermediate state bytes
        i2 = bytearray(AES.block_size)
        # Create an array to store the calculated plaintext bytes
        p2 = bytearray(AES.block_size)
        
        # Create an array to store calculated padding values (fake plaintext 
        # for XORing)
        p2Prime = bytearray(AES.block_size)
        
        # Loop backwards through each byte in a ciphertext block
        for byteIndex in reversed(range(AES.block_size)):
            
            # Loop from the current byte to the end of the p2 array
            for p2PrimeIndex in range(byteIndex, AES.block_size):
                
                # Set the current byte in p2Prime to the current padding value, 
                # which is calculated based on the position relative to the 
                # block size
                p2Prime[p2PrimeIndex] = AES.block_size - byteIndex
                
            # Loop from the byte after the current byte to the end of the 
            # c1Prime array
            for c1PrimeIndex in range(byteIndex + 1, AES.block_size):
                
                # Set the current byte in c1Prime to the XOR of the 
                # corresponding byte in i2 (previously calculated intermediate 
                # value) and the corresponding byte in p2Prime (previously 
                # calculated padding value)
                c1Prime[c1PrimeIndex] = i2[c1PrimeIndex] ^ p2Prime[c1PrimeIndex]
            
            # Array to hold ciphertext combinations to test in bulk
            ciphertextsToTry = []
            
            # Loop through each possible byte value from 0-255/0x00-0xFF
            for possibleValue in range(256):
                
                # Set the current byte in c1Prime to the current guess
                c1Prime[byteIndex] = possibleValue
                
                # Add the ciphertext combo to the array of combinations to test
                ciphertextsToTry.append(c1Prime + c2)
                
            # Test the combinations of c1Prime and c2 against the padding 
            # oracle
            results = oracle(oracle_url, ciphertextsToTry)
            
            # If any of the combinations resulted in a status value of 
            # "invalid_mac", valid padding has been found
            if any(result["status"] == "invalid_mac" for result in results):
                
                # Get the index of the modified ciphertext, which is the 
                # guessed byte value that resulted in correct padding
                correctGuess = [i for i, result in enumerate(results) if result["status"] == "invalid_mac"][0]
                
                # Set the current byte in i2 (the new intermediate value) 
                # to the XOR of the guessed byte value and the padding value
                i2[byteIndex] = correctGuess ^ p2Prime[byteIndex]
                
                # Set the current byte in p2 (the calculated plaintext 
                # value) to the XOR of the intermediate byte and the 
                # previous ciphertext block's corresponding value
                p2[byteIndex] = i2[byteIndex] ^ c1[byteIndex]
                
        # Append the newly calculated plaintext block to the array of 
        # plaintext blocks
        plaintextBlocks.append(p2)
        
    # Reverse the order of the plaintext blocks, since they were assembled in 
    # reverse
    plaintextBlocks.reverse()
    
    # Assemble the full plaintext
    plaintext = bytes().join(plaintextBlocks).hex()
    
    # Calculate the amount of leftover padding by getting the last byte of the 
    # assembled plaintext
    leftoverPadding = plaintext[len(plaintext) - 2:]
    # Remove the leftover padding, as well as the MAC (which isn't needed)
    plaintext = plaintext[:-((int(leftoverPadding, 16) + SHA256.digest_size) * 2)]
    
    # Print the plaintext
    print(bytes.fromhex(plaintext).decode("utf-8"))

if __name__ == '__main__':
    main()