#!/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