#!/usr/bin/env python
# -*- coding: UTF-8 -*-

"""
Relevation Password Printer
a command line interface to Revelation Password Manager.

Code based on Revelation's former BTS (no longer online, not archived?):
  (ref1) code:
  http://oss.wired-networks.net/bugzilla/attachment.cgi?id=13&action=view
  (ref2) bug report:
  http://oss.wired-networks.net/bugzilla/show_bug.cgi?id=111
    -> http://web.archive.org/http://oss.wired-networks.net/bugzilla/show_bug.cgi?id=111
(ref3) http://docs.python.org/library/zlib.html
(ref4) http://pymotw.com/2/getpass/

$Id: relevation.py 253 2013-11-04 23:48:09Z toni $
"""
# Relevation Password Printer
#
# Copyright (c) 2011,2012,2013 Toni Corvera
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

import ConfigParser
import getopt
import getpass
from lxml import etree
import os
import stat
import string
import sys
import zlib
# Help py2exe in packaging lxml
# <http://www.py2exe.org/index.cgi/WorkingWithVariousPackagesAndModules>
import lxml._elementpath as _dummy
import gzip # py2exe again

USE_PYCRYPTO = True

try:
    from Crypto.Cipher import AES
except ImportError:
    USE_PYCRYPTO = False
    try:
        from crypto.cipher import rijndael, cbc
        from crypto.cipher.base import noPadding
    except ImportError:
        sys.stderr.write('Either PyCrypto or cryptopy is required\n')
        raise

__author__ = 'Toni Corvera'
__date__ = '$Date: 2013-11-05 00:48:09 +0100 (Tue, 05 Nov 2013) $'
__revision__ = '$Rev: 253 $'
__version_info__ = ( 1, 2 , 1 ) # Note: For x.y.0, only x and y are kept
__version__ = '.'.join(map(str, __version_info__))
RELEASE=True

# These are pseudo-standardized exit codes, in Linux (*NIX?) they are defined
#+in the header </usr/include/sysexits.h> and available as properties of 'os'
#+In windows they aren't defined at all

if 'EX_OK' not in dir(os):
    # If not defined set them manually
    codes = { 'EX_OK': 0,       'EX_USAGE':    64, 'EX_DATAERR': 65,
              'EX_NOINPUT': 66, 'EX_SOFTWARE': 70, 'EX_IOERR': 74,
    }
    for (k,v) in codes.items():
        setattr(os, k, v)
    del codes, k, v

TAGNAMES ={ 'generic-url': 'Url:',
        'generic-username': 'Username:',
        'generic-password': 'Password:',
        'generic-email': 'Email:',
        'generic-hostname': 'Hostname:',
        'generic-location': 'Location:',
        'generic-code': 'Code:',
        'generic-certificate': 'Certificate:',
        'generic-database': 'Database:',
        'generic-domain': 'Domain:',
        'generic-keyfile': 'Key file:',
        'generic-pin': 'PIN',
        'generic-port': 'Port'
}
MODE_AND='and'
MODE_OR='or'

def printe(s):
    ' Print to stderr '
    sys.stderr.write(s+'\n')

def usage(channel):
    ' Print help message '
    def p(s):
        channel.write(s)
    p('%s {-f passwordfile} {-p password | -0} [search] [search2] [...]\n' % sys.argv[0])
    p('\nOptions:\n')
    # Reference: 80 characters
    #  -------------------------------------------------------------------------------
    p('  -f FILE, --file=FILE         Revelation password file.\n')
    p('  -p PASS, --password=PASS     Master password.\n')
    p('  -s SEARCH, --search=SEARCH   Search for string.\n')
    p('  -i, --case-insensitive       Case insensitive search (default).\n')
    p('  -c, --case-sensitive         Case sensitive search.\n')
    p('  -a, --ask                    Interactively ask for password.\n')
    p('                               Note it will be displayed in clear as you\n')
    p('                               type it.\n')
    p('  -t TYPE, --type=TYPE         Print only entries of type TYPE.\n')
    p('                               With no search string, prints all entries of\n')
    p('                               type TYPE.\n')
    p('  -A, --and                    When multiple search terms are used, use an AND\n')
    p('                               operator to combine them.\n')
    p('  -O, --or                     When multiple search terms are used, use an OR\n')
    p('                               operator to combine them.\n')
    p('  -x, --xml                    Dump unencrypted XML document.\n')
    p('  -0, --stdin                  Read password from standard input.\n')
    p('  -h, --help                   Print help (this message).\n')
    p('  --version                    Print the program\'s version information.\n')
    p('\n')

def make_xpath_query(search_text=None, type_filter=None, ignore_case=True, negate_filter=False):
    ''' Construct the actual XPath expression
    make_xpath_query(str, str, bool, bool) -> str
    or
    make_xpath_query(list, str, bool, bool) -> str

    Passing a list as the second argument implies combining its elements
    in the search (AND)
    '''
    xpath = '/revelationdata//entry'
    if type_filter:
        sign = '='
        if negate_filter:
            sign = '!='
        xpath = '%s[@type%s"%s"]' % ( xpath, sign, type_filter )
        if type_filter != 'folder':
            # Avoid printing folders since all their children are printed
            # alongside
            xpath += '[@type!="folder"]'
    if search_text:
        #xpath = xpath + '//text()'
        needles = []
        if type(search_text) == str:
            needles = [ search_text, ]
        else:
            needles = search_text
        selector = ''
        for search in needles:
            if ignore_case:
                # must pass lowercase to actually be case insensitive
                search = string.lower(search)
                # XPath 2.0 has lower-case, upper-case, matches(..., -i) etc.
                selector += '//text()[contains(translate(., "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "%s")]/../..' % search
            else:
                selector += '//text()[contains(., "%s")]/../..' % search
        xpath = '%s%s' % ( xpath, selector )
    if not RELEASE:
        printe("> Xpath: %s\n" % xpath)
    return xpath

def dump_all_entries(xmldata):
    ' Dump all entries from xmldata, with no filter at all '
    tree = etree.fromstring(xmldata)
    res = tree.xpath('//entry')
    return dump_result(res, 'all')

def dump_entries(xmldata, search_text=None, type_filter=None, ignore_case=True, negate_filter=False):
    ''' Dump entries from xmldata that match criteria
    dump_entries(str, str, str, bool, bool) -> int
    or
    dump_entries(str, list, str, bool, bool) -> int
    '''
    tree = etree.fromstring(xmldata)
    xpath = make_xpath_query(search_text, type_filter, ignore_case, negate_filter)
    try:
        res = tree.xpath(xpath)
    except etree.XPathEvalError:
        if not RELEASE:
            printe('Failed with xpath expression: %s' % xpath)
        raise
    query_desc = ''
    if search_text:
        query_desc = '"%s"' % search_text
    if type_filter:
        neg = ''
        if negate_filter:
            neg = 'not '
        if search_text:
            query_desc = '%s (\'%s%s\' entries)' % ( query_desc, neg, type_filter )
        else:
            query_desc = '%s%s entries' % ( neg, type_filter )
    nr = dump_result(res, query_desc)
    return nr

def dump_single_result(typeName, name, descr, notes, fields):
    printe('-------------------------------------------------------------------------------')
    s = '\n'
    s += 'Type: %s\n' % typeName
    s += 'Name: %s\n' % name
    s += 'Description: %s\n' % descr
    s += 'Notes: %s\n' % notes
    for field in fields:
        s += '%s %s\n' % field # field, value
    #s += '\n'
    print s

def dump_result(res, query_desc, dumpfn=dump_single_result):
    ''' Print query results.
    dump_result(list of entries, query description) -> int
    '''
    print '-> Search %s: ' % query_desc,
    if not len(res):
        print 'No results'
        return False
    print '%d matches' % len(res)
    for x in res:
        typeName = x.get('type')
        name = None
        descr = None
        fields = []
        notes = None
        for chld in x.getchildren():
            n = chld.tag
            val = chld.text
            if val is None:
                val = ''
            if n == 'name':
                name = val
            elif n == 'description':
                descr = val
            elif n == 'field':
                idv = chld.get('id')
                if idv in TAGNAMES:
                    idv = TAGNAMES[idv]
                val = chld.text
                if val is None:
                    val = ''
                # Maintain order => list
                fields += [ ( idv, val ), ]
            elif n == 'notes':
                notes = val
        dumpfn(typeName, name, descr, notes, fields)
        # / for chld in x.children
    nr = len(res)
    plural = ''
    if nr > 1:
        plural = 's'
    printe('-------------------------------------------------------------------------------')
    printe('<- (end of %d result%s for {%s})\n' % ( nr, plural, query_desc ))
    return nr

def world_readable(path):
    ' Check if a file is readable by everyone '
    assert os.path.exists(path)
    if sys.platform == 'win32':
        return True
    st = os.stat(path)
    return bool(st.st_mode & stat.S_IROTH)

def load_config():
    ''' Load configuration file is one is found
    load_config() -> ( str file, str pass )
    '''
    cfg = os.path.join(os.path.expanduser('~'), '.relevation.conf')
    pw = None
    fl = None
    mode = MODE_OR
    if os.path.isfile(cfg):
        if os.access(cfg, os.R_OK):
            wr = world_readable(cfg)
            if wr and sys.platform != 'win32':
                printe('Configuration (~/.relevation.conf) is world-readable!!!')
            parser = ConfigParser.ConfigParser()
            parser.read(cfg)
            ops = parser.options('relevation')
            if 'file' in ops:
                fl = os.path.expanduser(parser.get('relevation', 'file'))
            if 'password' in ops:
                if wr: # TODO: how to check in windows?
                    printe('Your password can be read by anyone!!!')
                pw = parser.get('relevation', 'password')
            if 'mode' in ops:
                mode = parser.get('relevation', 'mode')
                if mode not in [ MODE_AND, MODE_OR ]:
                    printe('Warning: Unknown mode \'%s\' set in configuration' % mode)
                    mode=MODE_OR
        else: # exists but not readable
            printe('Configuration file (~/.relevation.conf) is not readable!')
    return ( fl, pw, mode )

def decrypt_gz(key, cipher_text):
    ''' Decrypt cipher_text using key.
    decrypt(str, str) -> cleartext (gzipped xml)
    
    This function will use the underlying, available, cipher module.
    '''
    if USE_PYCRYPTO:
        # Extract IV
        c = AES.new(key)
        iv = c.decrypt(cipher_text[12:28])
        # Decrypt data, CBC mode
        c = AES.new(key, AES.MODE_CBC, iv)
        ct = c.decrypt(cipher_text[28:])
    else:
        # Extract IV
        c = rijndael.Rijndael(key, keySize=len(key), padding=noPadding())
        iv = c.decrypt(cipher_text[12:28])
        # Decrypt data, CBC mode
        bc = rijndael.Rijndael(key, keySize=len(key), padding=noPadding())
        c = cbc.CBC(bc, padding=noPadding())
        ct = c.decrypt(cipher_text[28:], iv=iv)
    return ct

def main(argv):
    datafile = None
    password = None
    # values to search for
    needles = []
    caseInsensitive = True
    # individual search: ( 'value to search', 'type of search', 'type of entry to filter' )
    searchTypes = []
    dump_xml = False
    mode = None

    printe('Relevation v%s, (c) 2011-2013 Toni Corvera\n' % __version__)

    # ---------- OPTIONS ---------- #
    ( datafile, password, mode ) = load_config()
    try:
        # gnu_getopt requires py >= 2.3
        ops, args = getopt.gnu_getopt(argv, 'f:p:s:0ciaht:xAO',
                        [ 'file=', 'password=', 'search=', 'stdin',
                          'case-sensitive', 'case-insensitive', 'ask',
                          'help', 'version', 'type=', 'xml',
                          'and', 'or' ])
    except getopt.GetoptError, err:
        print str(err)
        usage(sys.stderr)
        sys.exit(os.EX_USAGE)
    if args:
        needles = args
    
    if ( '-h', '' ) in ops or ( '--help', '' ) in ops:
        usage(sys.stdout)
        sys.exit(os.EX_OK)
    if ( '--version', '' ) in ops:
        release=''
        if not RELEASE:
            release=' [DEBUG]'
        print 'Relevation version %s%s' % ( __version__, release )
        print 'Python version %s' % sys.version
        if USE_PYCRYPTO:
            import Crypto
            print 'PyCrypto version %s' % Crypto.__version__
        else:
            # AFAIK cryptopy doesn't export version info
            print 'cryptopy'
        sys.exit(os.EX_OK)
    
    for opt, arg in ops:
        if opt in ( '-f', '--file' ):
            datafile = arg
        elif opt in ( '-p', '--password' ):
            password = arg
        elif opt in ( '-a', '--ask', '-0', '--stdin' ):
            prompt = ''
            if opt in ( '-a', '--ask' ):
                prompt = 'File password: '
            # see [ref4]
            if sys.stdin.isatty():
                password = getpass.getpass(prompt=prompt, stream=sys.stderr)
            else:
                # Not a terminal, getpass won't work
                password = sys.stdin.readline();
                password = password[:-1] # XXX: would .rstrip() be safe enough?
        elif opt in ( '-s', '--search' ):
            needles.append(arg)
        elif opt in ( '-i', '--case-insensitive' ):
            caseInsensitive = True
        elif opt in ( '-c', '--case-sensitive' ):
            caseInsensitive = False
        elif opt in ( '-t', '--type' ):
            iarg = arg.lower()
            neg = False
            if iarg.startswith('-'):
                iarg = iarg[1:]
                neg = True
            if not iarg in ( 'creditcard', 'cryptokey', 'database', 'door', 'email',
                            'folder', 'ftp', 'generic', 'phone', 'shell', 'website' ):
                printe('Warning: Type "%s" is not known by relevation.' % arg)
            searchTypes.append( ( iarg, neg ) )
        elif opt in ( '-x', '--xml' ):
            dump_xml = True
        elif opt in ( '-A', '--and' ):
            mode = MODE_AND
        elif opt in ( '-O', '--or' ):
            mode = MODE_OR
        else:
            printe('Unhandled option: %s' % opt)
            assert False, "internal error parsing options"
    if not datafile or not password:
        usage(sys.stderr)
        if not datafile:
            printe('Input password filename is required')
        if not password:
            printe('Password is required')
        sys.exit(os.EX_USAGE)
    
    # ---------- PASSWORDS FILE DECRYPTION AND DECOMPRESSION ---------- #
    f = None
    try:
        if not os.access(datafile, os.R_OK):
            raise IOError('File \'%s\' not accessible' % datafile)
        f = open(datafile, "rb")
        # Encrypted data
        data = f.read()
    finally:
        if f:
            f.close()
    # Pad password
    password += (chr(0) * (32 - len(password)))
    # Decrypt. Decrypted data is compressed
    cleardata_gz = decrypt_gz(password, data)
    # Length of data padding
    padlen = ord(cleardata_gz[-1])
    # Decompress actual data (15 is wbits [ref3] DON'T CHANGE, 2**15 is the (initial) buf size)
    xmldata = zlib.decompress(cleardata_gz[:-padlen], 15, 2**15)
    
    # ---------- QUERIES ---------- #
    if dump_xml:
        print xmldata
        sys.exit(os.EX_OK)
    # Multiply values to search by type of searches
    numhits = 0

    if not ( needles or searchTypes ): # No search nor filters, print all
        numhits = dump_all_entries(xmldata)
    elif not searchTypes: # Simple case, all searches are text searches
        if mode == MODE_OR:
            for text in needles:
                numhits += dump_entries(xmldata, text, 'folder', caseInsensitive, True)
        else:
            assert mode == MODE_AND, "Unknown boolean operation mode"
            numhits += dump_entries(xmldata, needles, 'folder', caseInsensitive, True)
    elif needles:
        if mode == MODE_OR: # Do a search filtered for each type
            for text in needles:
                for ( sfilter, negate ) in searchTypes:
                    numhits += dump_entries(xmldata, text, sfilter, caseInsensitive,
                                    negate_filter=negate)
        else: # Do a combined search, filter for each type
            assert mode == MODE_AND, "Unknown boolean operation mode"
            for ( sfilter, negate ) in searchTypes:
                numhits += dump_entries(xmldata, needles, sfilter, caseInsensitive,
                                    negate_filter=negate)
    else: # Do a search only of types
        for ( sfilter, negate ) in searchTypes:
            numhits += dump_entries(xmldata, None, sfilter, negate_filter=negate)
    if numhits == 0:
        sys.exit(80)

if __name__ == '__main__':
    try:
        main(sys.argv[1:])
    except zlib.error:
        printe('Failed to decompress decrypted data. Wrong password?')
        sys.exit(os.EX_DATAERR)
    except etree.XMLSyntaxError as e:
        printe('XML parsing error')
        if not RELEASE:
            traceback.print_exc()
        sys.exit(os.EX_DATAERR)
    except IOError as e:
        if not RELEASE:
            traceback.print_exc()
        printe(str(e))
        sys.exit(os.EX_IOERR)

# vim:set ts=4 et ai fileencoding=utf-8: #
