We start by checking the provided example:

curl -s --header "Content-Type: application/json" --data '{"message":"What am I doing here?", "key":"12345678"}' -k

We make some other requests:

curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"0"}' -k
{"result":"bad key format","Signature":"","StatusCode":400}
curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"0Z"}' -k
{"result":"bad key format","Signature":"","StatusCode":400}
curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"0a"}' -k
curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"00"}' -k
curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"0000"}' -k
curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"01"}' -k
curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"0001"}' -k
curl -s  --header "Content-Type: application/json" --data '{"message":"A", "key":"000001"}' -k
curl -s  --header "Content-Type: application/json" --data '{"message":"AAAAAAAAAAAAAAAAAAAA", "key":"00"}' -k

So we found out that:

  1. Before encryption the plaintext is padded. It similar padding to the familiar padding, except that the padding block size is 20 bytes.
  2. The key input is hex-encoded. The key is extended by repetition so it size will be the same as the size of the padded plaintext.
  3. The extended-key and the padded plaintext are XORed to form the ciphertext.
  4. The "Signature" is only depended on the final ciphertext. The signature can be forged easily as we provide the XOR-key and the message which allows us to create any ciphertext to get it's signature, no matter if it's "real" message and key are different.

This IS NOT a padding-oracle-attack, since the API needs us to provide a key and does not decrypt or encrypt with some secret key. This implies that we have all the knowledge that we needed already and that the API is useless for us from this point forward.

The only additional OOB information that we have is the format of the flag. Let us check what we can gain from using our current info.

def xor(v,u):
    return bytes(a^b for a,b in zip(v,u))
ct = bytes.fromhex('2c155e13121059000e06471817074562300609071a3a2d225b5f5c10143d4c0409001f0102060c0e0b466941040d5604521b1d0b440e410252185a1b181b0e05077b6660612f60612f62762f637a6868')
known_prefix = b'BSidesTLV2020{'
for i in range(len(ct)-len(known_prefix)):
     print(i, xor(ct[i:], known_prefix))
#0 b"nF7wwc\rLX4w*'|"
#15 b' combination o'
#65 b'95\t\x05J\x135c4D\x1fQJ\x13'
for j in range(1, 21):
    print(21 - j, bytes(j ^ ct[-i] for i in range(j, 0, -1)))
#1 b'i'
#2 b'jj'
#15 b'tion on my lugg'
#19 b'\x08\x1d\x16\x14husr<sr<qe<pi{{'
#20 b'\x0c\x0f\x1a\x11\x13ortu;tu;vb;wn||'

What we have so far:

  1. The known flag prefix starts at the 16 byte.
  2. The padding is 15 bytes long.
  3. The key probably includes the characters ' combination on my luggage' with an unknown 15 bytes prefix and maybe is longer than that.

Let's check 3:

key_part = b' combination on my luggage'
print(xor(ct[15:], key_part))
#b'BSidesTLV2020{Slip steamin'

Looking GOOD! Now we know that the next plaintext byte is probably 'g', lets check what the corresponding key byte should be:

chr(ct[15+len(key_part)] ^ ord('g'))

Well this is probably NOT the first key byte but the last and it comes after 'luggage'. With similar arguments we can find that:

  1. The first key-byte must be 'I' in order for the next byte in the flag to be a space.
  2. The byte before the known part of the key is probably 'e' in order to get a proper '}' byte for the flag from XORing only with printable characters.

By additional guesswork we arrive to a possible key 'I have the same combination on my luggage!' which gives:

guess_key = b'I have the same combination on my luggage!'
print(xor(ct, guess_key * (len(ct)//len(guess_key) + 1)))
b'e56rduytfcgkvj BSidesTLV2020{Slip steaming all around} o789tuygkf\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f'