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 17KB

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