revelation2pass.py (6223B)
1 #!/usr/bin/env python 2 # -*- coding: utf-8 -*- 3 # 4 # Copyright (C) 2013 Emanuele Aina <em@nerd.ocracy.org>. All Rights Reserved. 5 # Copyright (C) 2011 Toni Corvera. All Rights Reserved. 6 # This file is licensed under the BSD 2-clause license: 7 # http://www.opensource.org/licenses/BSD-2-Clause 8 # 9 # Import script for the Revelation password manager: 10 # http://revelation.olasagasti.info/ 11 # Heavily based on the Relevation command line tool: 12 # http://p.outlyer.net/relevation/ 13 14 import os, sys, argparse, zlib, getpass, traceback 15 from subprocess import Popen, PIPE, STDOUT, CalledProcessError 16 from collections import OrderedDict 17 try: 18 from lxml import etree 19 except ImportError: 20 from xml.etree import ElementTree as etree 21 22 USE_PYCRYPTO = True 23 try: 24 from Crypto.Cipher import AES 25 except ImportError: 26 USE_PYCRYPTO = False 27 try: 28 from crypto.cipher import rijndael, cbc 29 from crypto.cipher.base import noPadding 30 except ImportError: 31 sys.stderr.write('Either PyCrypto or cryptopy are required\n') 32 raise 33 34 def path_for(element, path=None): 35 """ Generate path name from elements name and current path """ 36 name = element.find('name').text 37 name = name.replace('/', '-').replace('\\', '-') 38 path = path if path else '' 39 return os.path.join(path, name) 40 41 def format_password_data(data): 42 """ Format the secret data that will be handed to Pass in multi-line mode: 43 $password 44 $fieldname: $fielddata 45 ... 46 $multi_line_notes_with_leading_spaces""" 47 password = data.pop('password', None) or '' 48 ret = password + '\n' 49 notes = data.pop('notes', None) 50 for label, text in data.iteritems(): 51 ret += label + ': ' + text + '\n' 52 if notes: 53 ret += ' ' + notes.replace('\n', '\n ').strip() + '\n' 54 return ret 55 56 def password_data(element): 57 """ Return password data and additional info if available from 58 password entry element. """ 59 data = OrderedDict() 60 try: 61 data['password'] = element.find('field[@id="generic-password"]').text 62 except AttributeError: 63 data['password'] = None 64 data['type'] = element.attrib['type'] 65 for field in element.findall('field'): 66 field_id = field.attrib['id'] 67 if field_id == 'generic-password': 68 continue 69 if field.text is not None: 70 data[field_id] = field.text 71 for tag in ('description', 'notes'): 72 field = element.find(tag) 73 if field is not None and field.text: 74 data[tag] = field.text 75 return format_password_data(data) 76 77 78 def import_entry(element, path=None, verbose=0): 79 """ Import new password entry to password-store using pass insert 80 command """ 81 cmd = ['pass', 'insert', '--multiline', '--force', path_for(element, path)] 82 if verbose: 83 print 'cmd:\n ' + ' '.join(cmd) 84 proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=STDOUT) 85 stdin = password_data(element).encode('utf8') 86 if verbose: 87 print 'input:\n ' + stdin.replace('\n', '\n ').strip() 88 stdout, _ = proc.communicate(stdin) 89 retcode = proc.poll() 90 if retcode: 91 raise CalledProcessError(retcode, cmd, output=stdout) 92 93 def import_folder(element, path=None, verbose=0): 94 path = path_for(element, path) 95 import_subentries(element, path, verbose) 96 97 def import_subentries(element, path=None, verbose=0): 98 """ Import all sub entries of the current folder element """ 99 for entry in element.findall('entry'): 100 if entry.attrib['type'] == 'folder': 101 import_folder(entry, path, verbose) 102 else: 103 import_entry(entry, path, verbose) 104 105 def decrypt_gz(key, cipher_text): 106 ''' Decrypt cipher_text using key. 107 decrypt(str, str) -> cleartext (gzipped xml) 108 109 This function will use the underlying, available, cipher module. 110 ''' 111 if USE_PYCRYPTO: 112 # Extract IV 113 c = AES.new(key) 114 iv = c.decrypt(cipher_text[12:28]) 115 # Decrypt data, CBC mode 116 c = AES.new(key, AES.MODE_CBC, iv) 117 ct = c.decrypt(cipher_text[28:]) 118 else: 119 # Extract IV 120 c = rijndael.Rijndael(key, keySize=len(key), padding=noPadding()) 121 iv = c.decrypt(cipher_text[12:28]) 122 # Decrypt data, CBC mode 123 bc = rijndael.Rijndael(key, keySize=len(key), padding=noPadding()) 124 c = cbc.CBC(bc, padding=noPadding()) 125 ct = c.decrypt(cipher_text[28:], iv=iv) 126 return ct 127 128 def main(datafile, verbose=False, xml=False): 129 f = None 130 with open(datafile, "rb") as f: 131 # Encrypted data 132 data = f.read() 133 if xml: 134 xmldata = data 135 else: 136 password = getpass.getpass() 137 # Pad password 138 password += (chr(0) * (32 - len(password))) 139 # Decrypt. Decrypted data is compressed 140 cleardata_gz = decrypt_gz(password, data) 141 # Length of data padding 142 padlen = ord(cleardata_gz[-1]) 143 # Decompress actual data (15 is wbits [ref3] DON'T CHANGE, 2**15 is the (initial) buf size) 144 xmldata = zlib.decompress(cleardata_gz[:-padlen], 15, 2**15) 145 root = etree.fromstring(xmldata) 146 import_subentries(root, verbose=verbose) 147 148 if __name__ == '__main__': 149 parser = argparse.ArgumentParser() 150 parser.add_argument('-x', '--xml', help='read plain XML file', action='store_true') 151 parser.add_argument('--verbose', '-v', action='count') 152 parser.add_argument('FILE', help="the file storing the Revelation passwords") 153 args = parser.parse_args() 154 155 def err(s): 156 sys.stderr.write(s+'\n') 157 158 try: 159 main(args.FILE, verbose=args.verbose, xml=args.xml) 160 except KeyboardInterrupt: 161 if args.verbose: 162 traceback.print_exc() 163 err(str(e)) 164 except zlib.error: 165 err('Failed to decompress decrypted data. Wrong password?') 166 sys.exit(os.EX_DATAERR) 167 except CalledProcessError as e: 168 if args.verbose: 169 traceback.print_exc() 170 print 'output:\n ' + e.output.replace('\n', '\n ').strip() 171 else: 172 err('CalledProcessError: ' + str(e)) 173 sys.exit(os.EX_IOERR) 174 except IOError as e: 175 if args.verbose: 176 traceback.print_exc() 177 else: 178 err('IOError: ' + str(e)) 179 sys.exit(os.EX_IOERR)