Source code for enpassreaderlib.enpassreaderlib

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# File: enpassreaderlib.py
#
# Copyright 2021 Costas Tyfoxylos
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to
#  deal in the Software without restriction, including without limitation the
#  rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
#  sell copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
#  all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
#  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
#  DEALINGS IN THE SOFTWARE.
#

"""
Main code for enpassreaderlib.

.. _Google Python Style Guide:
   http://google.github.io/styleguide/pyguide.html

"""

import binascii
import hashlib
import logging

from pathlib import Path

from Crypto.Cipher import AES
from pysqlcipher3 import dbapi2 as sqlite

from .enpassreaderlibexceptions import EnpassDatabaseError

__author__ = '''Costas Tyfoxylos <costas.tyf@gmail.com>'''
__docformat__ = '''google'''
__date__ = '''25-03-2021'''
__copyright__ = '''Copyright 2021, Costas Tyfoxylos'''
__credits__ = ["Costas Tyfoxylos"]
__license__ = '''MIT'''
__maintainer__ = '''Costas Tyfoxylos'''
__email__ = '''<costas.tyf@gmail.com>'''
__status__ = '''Development'''  # "Prototype", "Development", "Production".


# This is the main prefix used for logging
LOGGER_BASENAME = 'enpassreaderlib'
LOGGER = logging.getLogger(LOGGER_BASENAME)
LOGGER.addHandler(logging.NullHandler())


[docs]class EnpassDB: """Manages the database object exposing useful methods to interact with it.""" def __init__(self, database_path, password, keyfile=None, pbkdf2_rounds=100_000): self._database_path = database_path self._password = password.encode('utf-8') self._keyfile = keyfile self.pbkdf2_rounds = pbkdf2_rounds self._master_password = None self._cipher_key = None self._connection, self._cursor = self._authenticate() @property def _retrieve_all_query(self): field_types = ['password', 'totp'] return ('SELECT ' 'i.title, ' 'i.uuid, ' 'i.key, ' 'if_password.value as password_value, ' 'if_password.hash as password_value_hash, ' 'if_totp.value as totp_value, ' 'if_totp.hash as totp_value_hash ' 'FROM item i ' + ''.join([(f'LEFT JOIN ' f'(SELECT item_uuid, type, value, hash ' f'FROM itemfield WHERE type = "{type_}") if_{type_} ' f'ON i.uuid = if_{type_}.item_uuid ') for type_ in field_types])) @property def master_password(self): """The master password calculated along with the key if provided else the password provided. Returns: master_password (bytearray): The master password to decrypt the database. """ if self._master_password is None: if self._keyfile: key_hex_xml = Path(self._keyfile).read_bytes() key_bytes = binascii.unhexlify(key_hex_xml[slice(5, -6)]) self._password = self._password + key_bytes self._master_password = self._password return self._master_password @property def cipher_key(self): """The cipher key to decrypt entries in the database. Returns: cipher_key (string): The cipher key to decrypt the database entries. """ if self._cipher_key is None: # The first 16 bytes of the database file are used as salt with open(self._database_path, "rb") as db: enpass_db_salt = db.read(16) # The database key is derived from the master password # and the database salt with 100k iterations of PBKDF2-HMAC-SHA512 enpass_db_key = hashlib.pbkdf2_hmac("sha512", self.master_password, enpass_db_salt, self.pbkdf2_rounds) # The raw key for the sqlcipher database is given # by the first 64 characters of the hex-encoded key self._cipher_key = enpass_db_key.hex()[:64] return self._cipher_key def _authenticate(self): try: connection = sqlite.connect(self._database_path) cursor = connection.cursor() cursor.row_factory = sqlite.Row cursor.execute(f"PRAGMA key=\"x'{self.cipher_key}'\";") cursor.execute('PRAGMA cipher_compatibility = 3;') cursor.execute('SELECT * FROM Identity;').fetchone() except sqlite.DatabaseError: raise EnpassDatabaseError('Either the master password or the key file provided cannot decrypt ' 'the database, or it is not a valid enpass 6 encrypted database.') from None return connection, cursor def _query(self, query): self._cursor.execute(query) # If you deleted an item from Enpass, it stays in the database, but the # entries are cleared so only entries with nonce are valid entries return [row for row in self._cursor if row["key"][32:]] @property def entries(self): """All the entries in the database. Returns: entries (list): The password entries in the database. """ return [Entry(row) for row in self._query(f'{self._retrieve_all_query};')]
[docs] def get_entry(self, name): """Retrieves a single entry matching the name. Args: name: The name of the password entry to retrieve. Returns: entry (Entry): A password entry object if match found else None. """ query = f'{self._retrieve_all_query} WHERE lower(i.title) = \"{name.lower()}\";' row = next((row for row in self._query(query)), None) if row is None: return row return Entry(row)
[docs] def search_entries(self, name): """Retrieves any entry that matches the name provided (fuzzy matching). Args: name: The name to search the password entries for. Returns: entries (list): A list of password entries matching the fuzzy search for the given name. """ query = f'{self._retrieve_all_query} WHERE lower(i.title) LIKE \"%{name.lower()}%\";' return [Entry(row) for row in self._query(query)]
[docs]class Entry: """Models a password entry and exposes some useful attributes about it.""" def __init__(self, database_row): # The key object is saved in binary from and actually consists of the # AES key (32 bytes) and a nonce (12 bytes) for GCM self.key = database_row["key"][:32] self.nonce = database_row["key"][32:] self.title = database_row["title"] self._password_value = database_row["password_value"] self._password_hash = database_row["password_value_hash"] self.uuid = database_row["uuid"] self._totp_hash = database_row["totp_value_hash"] self._totp = database_row["totp_value"] self.header = self.uuid.replace("-", "") self._password = None @property def password(self): """The plaintext password of the entry. Returns: password (text): The plaintext password of the entry. """ if self._password is None: # The value object holds the ciphertext (same length as plaintext) + # (authentication) tag (16 bytes) and is stored in hex try: ciphertext = bytearray.fromhex(self._password_value[:len(self._password_value) - 32]) except TypeError: LOGGER.warning(f'Entry with title :{self.title} and ' f'uuid :{self.uuid} does not seem to have a password.') return None # Now we can initialize, decrypt the ciphertext and verify the AAD. # You can compare the SHA-1 output with the value stored in the db cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.nonce) cipher.update(bytearray.fromhex(self.header)) self._password = cipher.decrypt(ciphertext).decode("utf-8") return self._password @property def totp_seed(self): return self._totp