otrdecoder - decoder for .otrkey files
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

otrdecoder 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. #!/usr/local/bin/python3
  2. # verify the path of python3, it might be /usr/bin/python3
  3. # To use python v2.7 change the first line to:
  4. #!/usr/bin/python
  5. # Copyright (C) 2017 Bernhard Ehlers
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  19. """
  20. otrdecoder - decoder for .otrkey files.
  21. usage: otrdecoder [-h] [-v] -i FILE -e EMAIL -p PASSWORD [-o OUTDIR] [-f] [-q]
  22. optional arguments:
  23. -h, --help, -? prints this screen
  24. -v prints version
  25. -i FILE use FILE as input file
  26. -e EMAIL use EMAIL to fetch the key directly from otr
  27. -p PASSWORD use PASSWORD to fetch the key directly from otr
  28. -o OUTDIR use OUTDIR as output directory (default: .)
  29. -f force overwriting of output file
  30. -q don't verify input file before processing.
  31. """
  32. __version__ = "0.1"
  33. import argparse
  34. import binascii
  35. import hashlib
  36. import os.path
  37. import random
  38. import sys
  39. import time
  40. from datetime import datetime
  41. from Crypto.Cipher import Blowfish
  42. # use http.client, fallback to httplib for Python 2
  43. try:
  44. from http.client import HTTPConnection, HTTPException
  45. except ImportError:
  46. from httplib import HTTPConnection, HTTPException
  47. BUF_SIZE = 1024*1024
  48. DECODER_VERSION = "0.4.1133"
  49. FILE_HDR_LEN = 10 + 512
  50. OTR_HOST = "www.onlinetvrecorder.com"
  51. def die(*msg_list):
  52. """ abort program with error message """
  53. error_msg = ' '.join(str(x) for x in msg_list)
  54. sys.exit(error_msg.rstrip("\n\r"))
  55. def write(msg):
  56. """ print to stdout, unbuffered and without adding '\n' """
  57. sys.stdout.write(msg)
  58. sys.stdout.flush()
  59. def swap_endian(data):
  60. """ convert between big-endian and little-endian (and vice versa) """
  61. # Swap byte order in each DWORD
  62. endian_swapped = bytearray(len(data))
  63. endian_swapped[0::4] = data[3::4]
  64. endian_swapped[1::4] = data[2::4]
  65. endian_swapped[2::4] = data[1::4]
  66. endian_swapped[3::4] = data[0::4]
  67. return bytes(endian_swapped)
  68. def md5(data):
  69. """ calculate MD5 hash """
  70. if isinstance(data, str):
  71. data = data.encode('utf-8')
  72. md5_hash = hashlib.md5()
  73. md5_hash.update(data)
  74. return md5_hash.hexdigest().upper()
  75. def parse_file_header(file):
  76. """ parse OTR file header """
  77. hardkey = binascii.a2b_hex('EF3AB29CD19F0CAC5759C7ABD12CC92BA3FE0AFEBF960D63FEBD0F45')
  78. # read header and check magic
  79. header = file.read(FILE_HDR_LEN)
  80. if len(header) != FILE_HDR_LEN or header[:10] != b'OTRKEYFILE':
  81. die("OTRkey file has bad format.")
  82. # decipher header
  83. cipher = Blowfish.new(hardkey, Blowfish.MODE_ECB)
  84. header = swap_endian(cipher.decrypt(swap_endian(header[10:])))
  85. header_len = header.find(b'&PD=')
  86. if header_len < 0:
  87. die("Corrupted file header: could not find padding.")
  88. try:
  89. header = header[:header_len].decode("ascii")
  90. except UnicodeError:
  91. die("Corrupted file header: invalid data.")
  92. # split header
  93. file_info = {}
  94. for option in header.split("&"):
  95. key_value = option.split("=", 1)
  96. if len(key_value) == 2:
  97. file_info[key_value[0]] = key_value[1]
  98. # check for important keys
  99. for key in ("FN", "FH", "OH", "SZ"):
  100. if key not in file_info:
  101. die("Corrupted file header: key '{}' is missing.".format(key))
  102. return file_info
  103. def generate_bigkey(email, password, utc_date):
  104. """ generate key for sending/receiving requests to the OTR server """
  105. mail_hash = md5(email)
  106. pass_hash = md5(password)
  107. bigkey_hex = mail_hash[0:13] + \
  108. utc_date[0:4] + \
  109. pass_hash[0:11] + \
  110. utc_date[4:6] + \
  111. mail_hash[21:32] + \
  112. utc_date[6:8] + \
  113. pass_hash[19:32]
  114. return binascii.a2b_hex(bigkey_hex)
  115. def generate_request(email, password, utc_date, file_info, bigkey):
  116. """ generate request string """
  117. request = "&A=" + email + \
  118. "&P=" + password + \
  119. "&FN=" + file_info["FN"] + \
  120. "&OH=" + file_info["OH"] + \
  121. "&M=" + md5("Foo Bar") + \
  122. "&OS=" + md5("Windows") + \
  123. "&LN=DE" + \
  124. "&VN=" + DECODER_VERSION + \
  125. "&IR=TRUE" + \
  126. "&IK=aFzW1tL7nP9vXd8yUfB5kLoSyATQ" + \
  127. "&D="
  128. request += ''.join(random.choice("0123456789ABCDEF") \
  129. for _ in range(512 - 8 - len(request)))
  130. # encrypt request
  131. bf_iv = bytes(bytearray(random.randint(0, 255) for _ in range(8)))
  132. cipher = Blowfish.new(bigkey, Blowfish.MODE_CBC, swap_endian(bf_iv))
  133. request = bf_iv + \
  134. swap_endian(cipher.encrypt(swap_endian(request.encode('ascii'))))
  135. # convert to a HTTP request
  136. request = "/quelle_neu1.php" + \
  137. "?code=" + binascii.b2a_base64(request)[:-1].decode('ascii') + \
  138. "&AA=" + email + \
  139. "&ZZ=" + utc_date
  140. return request
  141. def query_server(email, password, utc_date, file_info, bigkey):
  142. """ send request to OTR server and return its response """
  143. request = generate_request(email, password, utc_date, file_info, bigkey)
  144. try:
  145. # connect to host
  146. conn = HTTPConnection(OTR_HOST)
  147. # send request
  148. headers = {'User-Agent': 'Windows-OTR-Decoder/' + DECODER_VERSION,
  149. 'Accept': '*/*'}
  150. conn.request('GET', request, headers=headers)
  151. # get response, check for HTTP errors
  152. resp = conn.getresponse()
  153. response = resp.read()
  154. if resp.status < 200 or resp.status >= 300:
  155. die("HTTP failure: {} - {}".format(resp.status, resp.reason))
  156. except (HTTPException, IOError, OSError) as err:
  157. die("HTTP Exception:", err)
  158. return response
  159. def get_keyphrase(email, password, file_info):
  160. """ get keyphrase from remote OTR server """
  161. # query server for keyphrase
  162. utc_date = datetime.utcnow().strftime("%Y%m%d")
  163. bigkey = generate_bigkey(email, password, utc_date)
  164. response = query_server(email, password, utc_date, file_info, bigkey)
  165. # check for error message
  166. if response[:27] == b'MessageToBePrintedInDecoder':
  167. response = response[27:].lstrip().splitlines()[0]
  168. die('Response message:', response.decode('utf-8', 'replace'))
  169. # decrypt response
  170. try:
  171. response = binascii.a2b_base64(response)
  172. except binascii.Error:
  173. die("Corrupted response: invalid data.")
  174. if len(response) < 16 or len(response) % 8 != 0:
  175. die("Corrupted response: length must be a multiple of 8.")
  176. bf_iv = response[0:8]
  177. cipher = Blowfish.new(bigkey, Blowfish.MODE_CBC, swap_endian(bf_iv))
  178. response = swap_endian(cipher.decrypt(swap_endian(response[8:])))
  179. # strip padding
  180. resp_len = response.find(b'&D=')
  181. if resp_len < 0:
  182. die("Corrupted response: could not find padding")
  183. try:
  184. response = response[:resp_len].decode("ascii")
  185. except UnicodeError:
  186. die("Corrupted response: invalid data.")
  187. # search for keyphrase
  188. for option in response.split("&"):
  189. key_value = option.split("=", 1)
  190. if len(key_value) == 2 and key_value[0] == "HP":
  191. keyphrase = key_value[1]
  192. break
  193. else:
  194. die("Response lacks keyphrase")
  195. if len(keyphrase) != 56:
  196. die("Keyphrase has wrong length")
  197. try:
  198. keyphrase = binascii.a2b_hex(keyphrase)
  199. except (TypeError, binascii.Error):
  200. die("Keyphrase has invalid data.")
  201. return keyphrase
  202. class ShowProgress():
  203. """ show progress info """
  204. def __init__(self, maximum=100, text="Progress: {:3d}%\r"):
  205. self._maximum = float(maximum)
  206. self._text = text
  207. self._progress = -1
  208. def show(self, current):
  209. """ show current percentage """
  210. _progress = int(0.01 + 100.0 * float(current) / self._maximum)
  211. if _progress != self._progress:
  212. self._progress = _progress
  213. write(self._text.format(_progress))
  214. def end(self):
  215. """ end display of progress info """
  216. self._progress = -1
  217. write("\n")
  218. def verify_file(in_out, file, file_size, file_hash):
  219. """ verify file """
  220. write("Verifying {}...\n".format(in_out.lower()))
  221. # get target MD5
  222. if len(file_hash) != 48:
  223. die("Hash in file header has unexpected format.")
  224. try:
  225. md5_checksum = "".join(file_hash[i:i+2] for i in range(0, 48, 3))
  226. md5_checksum = binascii.a2b_hex(md5_checksum)
  227. except (TypeError, binascii.Error):
  228. die("Hash in file header has unexpected format.")
  229. # read file
  230. md5_hash = hashlib.md5()
  231. file_size = int(file_size) - FILE_HDR_LEN
  232. progress = ShowProgress(file_size)
  233. offset = 0
  234. while True:
  235. buffer = file.read(BUF_SIZE)
  236. if not buffer:
  237. break
  238. offset += len(buffer)
  239. if offset > file_size:
  240. progress.show(file_size)
  241. progress.end()
  242. die(in_out + " file contains trailing garbage.")
  243. md5_hash.update(buffer)
  244. progress.show(offset)
  245. progress.end()
  246. if offset < file_size:
  247. die(in_out + " file is too short.")
  248. # check MD5
  249. if md5_hash.digest() != md5_checksum:
  250. die(in_out + " file verification failed.")
  251. def verify_input(input_file, file_info):
  252. """ verify input file """
  253. input_file.seek(FILE_HDR_LEN, 0)
  254. verify_file("Input", input_file, file_info['SZ'], file_info['OH'])
  255. input_file.seek(FILE_HDR_LEN, 0)
  256. write("Successfully verified.\n")
  257. def verify_output(output_file, file_info):
  258. """ verify output file """
  259. output_file.seek(0, 0)
  260. verify_file("Output", output_file, file_info['SZ'], file_info['FH'])
  261. output_file.seek(0, 2)
  262. def decode_file(input_file, output_file, file_info, keyphrase):
  263. """ decode file """
  264. write("Decoding...\n")
  265. # decode with keyphrase from OTR server
  266. cipher = Blowfish.new(keyphrase, Blowfish.MODE_ECB)
  267. file_size = int(file_info['SZ']) - FILE_HDR_LEN
  268. progress = ShowProgress(file_size)
  269. offset = 0
  270. too_long = False
  271. while True:
  272. buffer = input_file.read(BUF_SIZE)
  273. data_len = len(buffer)
  274. if offset + data_len > file_size: # stop at file_size
  275. too_long = True
  276. data_len = file_size - offset
  277. buffer = buffer[:data_len]
  278. if data_len < BUF_SIZE:
  279. break
  280. buffer = swap_endian(cipher.decrypt(swap_endian(buffer)))
  281. output_file.write(buffer)
  282. offset += data_len
  283. progress.show(offset)
  284. # last block
  285. if data_len > 0:
  286. crypt_len = data_len - data_len % 8
  287. buffer = swap_endian(cipher.decrypt(swap_endian(buffer[:crypt_len]))) \
  288. + buffer[crypt_len:]
  289. output_file.write(buffer)
  290. offset += data_len
  291. progress.show(offset)
  292. progress.end()
  293. # error checks
  294. if too_long:
  295. write("Warning: Input file contains trailing garbage.\n")
  296. elif offset < file_size:
  297. write("Warning: Input file is truncated.\n")
  298. def main(argv):
  299. """ main """
  300. # parse command line
  301. parser = argparse.ArgumentParser(add_help=False, \
  302. description='%(prog)s - decoder for .otrkey files.')
  303. parser.add_argument('-h', '--help', '-?', action='help',
  304. help='prints this screen')
  305. parser.add_argument('-v', action='version',
  306. version="%(prog)s v" + __version__ + \
  307. " (emulates v" + DECODER_VERSION+")",
  308. help='prints version')
  309. parser.add_argument('-i', dest='input', metavar='FILE', required=True,
  310. help='use FILE as input file')
  311. parser.add_argument('-e', dest='email', metavar='EMAIL', required=True,
  312. help='use EMAIL to fetch the key directly from otr')
  313. parser.add_argument('-p', dest='password', metavar='PASSWORD', required=True,
  314. help='use PASSWORD to fetch the key directly from otr')
  315. parser.add_argument('-o', dest='outdir', metavar='OUTDIR', default='.',
  316. help='use OUTDIR as output directory (default: .)')
  317. parser.add_argument('-f', dest='force', action='store_true',
  318. help='force overwriting of output file')
  319. parser.add_argument('-q', dest='verify', action='store_false',
  320. help='don\'t verify input file before processing.')
  321. args = parser.parse_args(argv[1:])
  322. # process file
  323. try:
  324. with open(args.input, "rb") as otr_file:
  325. # read header
  326. otr_info = parse_file_header(otr_file)
  327. out_fname = os.path.join(args.outdir, otr_info['FN'])
  328. if not args.force and os.path.exists(out_fname):
  329. die("Output file already exists.")
  330. # verify input file
  331. if args.verify:
  332. verify_input(otr_file, otr_info)
  333. time.sleep(0.1)
  334. # get keyphrase for decoding rest of file
  335. otr_keyphrase = get_keyphrase(args.email, args.password, otr_info)
  336. # decode and save payload
  337. with open(out_fname, "w+b") as out_file:
  338. decode_file(otr_file, out_file, otr_info, otr_keyphrase)
  339. if args.verify:
  340. time.sleep(0.1)
  341. verify_output(out_file, otr_info)
  342. except (IOError, OSError) as err:
  343. die("I/O Error:", err)
  344. if __name__ == "__main__":
  345. main(sys.argv)