Source code for avendesora.obscure

# ObscuredSecret Information
#
# Defines classes used to conceal or encrypt information found in the accounts
# file.

# License {{{1
# Copyright (C) 2016-2021 Kenneth S. Kundert
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program.  If not, see http://www.gnu.org/licenses/.


# Imports {{{1
from .config import get_setting
from .error import PasswordError
from .gpg import GnuPG
from .utilities import error_source
from inform import indent, is_str, full_stop, cull
from binascii import a2b_base64, b2a_base64, Error as BinasciiError
from textwrap import dedent
import re


# Utilities {{{1
def chunk(string, length):
    return (
        string[0+i:length+i] for i in range(0, len(string), length)
    )

def decorate_concealed(name, encoded):
    if len(encoded) <= 60:
        arg = "'" + encoded + "'"
    else:
        arg = "\n    '" + "'\n    '".join(chunk(encoded, 60)) + "'\n"
    return '{}({})'.format(name, arg)

def group(pattern):
    return '(?:%s)' % pattern


STRING1 = group('"[^"]+"')
STRING2 = group("'[^']+'")
STRING3 = group("'''.+'''")
STRING4 = group('""".+"""')
SMPL_STRING = group('{s1}|{s2}'.format(s1=STRING1, s2=STRING2))
ML_STRING = group('{s3}|{s4}'.format(s3=STRING3, s4=STRING4))
ID = r"\w+"
DECORATED_LIST = re.compile(
    # Matches:
    #     Scrypt(
    #         "..."
    #         "..."
    #     )
    r"\s*({id})\s*\(\s*((?:{s}\s*)*)\s*\)\s*".format(id=ID, s=SMPL_STRING)
)
DECORATED_TEXT = re.compile(
    # Matches:
    #     GPG("""
    #         ...
    #         ...
    #     """)
    r"\s*({id})\s*\(\s*({s})\s*\)\s*".format(id=ID, s=ML_STRING),
    re.DOTALL,
)


# ObscuredSecret {{{1
class ObscuredSecret(object):
    """Base class for obscured secrets."""

    # obscurers() {{{2
    @classmethod
    def obscurers(cls):
        for sub in sorted(cls.__subclasses__(), key=lambda s: s.__name__):
            if hasattr(sub, 'conceal') and hasattr(sub, 'reveal'):
                yield sub
                for each in sub.obscurers():
                    if hasattr(each, 'conceal') and hasattr(each, 'reveal'):
                        yield each

    # get_name() {{{2
    @classmethod
    def get_name(cls):
        try:
            return cls.NAME.lower()
        except AttributeError:
            # consider converting lower to upper case transitions in __name__ to
            # dashes.
            return cls.__name__.lower()

    # get_description() {{{2
    @classmethod
    def get_description(cls):
        return None

    # hide() {{{2
    @classmethod
    def hide(cls, text, encoding=None, decorate=False, symmetric=False, gpg_ids=None):
        encoding = encoding.lower() if encoding else 'base64'
        for obscurer in cls.obscurers():
            if encoding == obscurer.get_name():
                return obscurer.conceal(
                    text, decorate, symmetric=symmetric, gpg_ids=gpg_ids
                )
        raise PasswordError('not found.', culprit=encoding)

    # show() {{{2
    @classmethod
    def show(cls, text):
        match = DECORATED_LIST.match(text)
        if match:
            name = match.group(1)
            value = ''.join([s.strip('"' "'") for s in match.group(2).split()])

            for obscurer in cls.obscurers():
                if name == obscurer.__name__:
                    return obscurer.reveal(value)
            raise PasswordError('not found.', culprit=name)

        match = DECORATED_TEXT.match(text)
        if match:
            name = match.group(1)
            value = match.group(2)

            for obscurer in cls.obscurers():
                if name == obscurer.__name__:
                    return obscurer.reveal(value.strip('"' "'"))
            raise PasswordError('not found.', culprit=name)

        return Hidden.reveal(text)

    # encodings() {{{2
    @classmethod
    def encodings(cls):
        for c in cls.obscurers():
            yield c.get_name(), dedent(getattr(c, 'DESC', '')).strip()

    # default encoding() {{{2
    @classmethod
    def default_encoding(cls):
        return Hidden.NAME

    # __repr__() {{{2
    def __repr__(self):
        secret = ObscuredSecret.hide(self.plaintext, 'base64')
        if hasattr(self, 'is_secret') and not self.is_secret:
            return "Hidden('{}', is_secret=False)".format(secret)
        else:
            return "Hidden('{}')".format(secret)

    # __str__() {{{2
    def __str__(self):
        return self.render()


# Hide {{{1
[docs]class Hide(ObscuredSecret): """Hide text Marks a value as being secret. Args: plaintext (str): The value of interest. secure (bool): Indicates that this secret is of high value and so should not be found in an unencrypted accounts file. is_secret (bool): Should value be hidden from user unless explicitly requested. """ NAME = 'base64' DESC = ''' Marks a value as being secret but the secret is not encoded in any way. Generally used in encrypted accounts files or on very low-value secrets. ''' def __init__(self, plaintext, *, secure=True, is_secret=True): self.plaintext = plaintext self.is_secret = is_secret def is_secure(self): return self.secure def render(self): return self.plaintext
# Hidden {{{1
[docs]class Hidden(ObscuredSecret): """Hidden text This encoding obscures but does not encrypt the text. Args: encoded_text (str): The value of interest encoded in base64. secure (bool): Indicates that this secret is of high value and so should not be found in an unencrypted accounts file. encoding (str): The encoding to use for the decoded text. is_secret (bool): Should value be hidden from user unless explicitly requested. Raises: :exc:`avendesora.PasswordError`: invalid value. """ NAME = 'base64' DESC = ''' This encoding obscures but does not encrypt the text. It can protect text from observers that get a quick glance of the encoded text, but if they are able to capture it they can easily decode it. ''' def __init__(self, encoded_text, *, secure=True, encoding=None, is_secret=True): self.encoded_text = encoded_text encoding = encoding if encoding else get_setting('encoding') try: self.plaintext = a2b_base64(encoded_text).decode(encoding) self.secure = secure except BinasciiError as e: raise PasswordError( 'invalid value specified to Hidden(): %s.' % str(e), culprit=error_source() ) self.is_secret = is_secret def is_secure(self): return self.secure def render(self): return self.plaintext @staticmethod def conceal(plaintext, decorate=False, encoding=None, **kwargs): encoding = encoding if encoding else get_setting('encoding') plaintext = str(plaintext).encode(encoding) encoded = b2a_base64(plaintext).rstrip().decode('ascii') if decorate: return decorate_concealed('Hidden', encoded) else: return encoded @staticmethod def reveal(value, encoding=None): encoding = encoding if encoding else get_setting('encoding') try: value = a2b_base64(value.encode('ascii')) return value.decode(encoding) except BinasciiError as e: raise PasswordError( str(e), template='Unable to decode base64 string: {0}.' ) except UnicodeDecodeError: raise PasswordError('Unable to decode base64 string.')
# GPG {{{1
[docs]class GPG(ObscuredSecret, GnuPG): """GPG encrypted text The secret is fully encrypted with GPG. Both symmetric encryption and key-based encryption are supported. Args: ciphertext (str): The secret encrypted and armored by GPG. encoding (str): The encoding to use for the deciphered text. Raises: :exc:`avendesora.PasswordError`: invalid value. """ DESC = ''' This encoding fully encrypts/decrypts the text with GPG key. By default your GPG key is used, but you can specify symmetric encryption, in which case a passphrase is used. ''' # This does a full GPG decryption. # To generate an entry for the GPG argument, you can use ... # gpg -a -c filename # It will create filename.asc. Copy the contents of that file into the # argument. # This uses symmetric encryption to add an additional layer of protection. # Generally one would use their private key to protect the gpg file, and # then use a symmetric key, or perhaps a separate private key, to protect an # individual piece of data, like a master seed. def __init__(self, ciphertext, *, secure=True, encoding=None): self.ciphertext = ciphertext self.encoding = encoding def initialize(self, account, field_name, field_key=None): # must do this here in initialize rather than in constructor to avoid # decrypting this, and perhaps asking for a passcode, every time # Avendesora is run. decrypted = self.gpg.decrypt(dedent(self.ciphertext)) if not decrypted.ok: msg = 'unable to decrypt argument to GPG()' try: msg = '%s: %s' % (msg, decrypted.stderr) except AttributeError: msg += '.' raise PasswordError(msg, culprit=error_source()) encoding = self.encoding if not self.encoding: encoding = get_setting('encoding') self.plaintext = decrypted.data.decode(encoding) def render(self): return str(self.plaintext) @classmethod def conceal( cls, plaintext, decorate=False, encoding=None, symmetric=False, gpg_ids=None ): encoding = encoding if encoding else get_setting('encoding') plaintext = str(plaintext).encode(encoding) if not gpg_ids: gpg_ids = get_setting('gpg_ids', []) if is_str(gpg_ids): gpg_ids = gpg_ids.split() encrypted = cls.gpg.encrypt( plaintext, gpg_ids, armor=True, symmetric=bool(symmetric) ) if not encrypted.ok: msg = ' '.join(cull([ 'unable to encrypt.', getattr(encrypted, 'stderr', None) ])) raise PasswordError(msg) ciphertext = str(encrypted) if decorate: return 'GPG("""\n%s""")' % indent(ciphertext) else: return ciphertext @classmethod def reveal(cls, ciphertext, encoding=None): decrypted = cls.gpg.decrypt(dedent(ciphertext)) if not decrypted.ok: msg = 'unable to decrypt argument to GPG()' try: msg = '%s: %s' % (msg, decrypted.stderr) except AttributeError: pass raise PasswordError(full_stop(msg)) plaintext = str(decrypted) return plaintext
# Scrypt {{{1 def scrypt_not_installed(): raise PasswordError(dedent(''' Scrypt based encryption and decryption is not available. To use, you must install scrypt support using: pip3 install scrypt ''').strip())
[docs]class Scrypt(ObscuredSecret): """Scrypt encrypted text The secret is fully encrypted with scrypt. Only available if scrypt is installed (pip install scrypt). Args: ciphertext (str): The secret encrypted and armored by GPG. encoding (str): The encoding to use for the deciphered text. Raises: :exc:`avendesora.PasswordError`: invalid value. """ # This encrypts/decrypts a string with scrypt. The user's key is used as the # passcode for this symmetric encryption. DESC = ''' This encoding fully encrypts the text with your user key. Only you can decrypt it, secrets encoded with scrypt cannot be shared. ''' def __init__(self, ciphertext, *, secure=True, encoding='utf8'): self.ciphertext = ciphertext self.encoding = encoding def initialize(self, account, field_name, field_key=None): try: import scrypt except ImportError: scrypt_not_installed() encrypted = a2b_base64(self.ciphertext.encode(self.encoding)) self.plaintext = scrypt.decrypt(encrypted, get_setting('user_key')) def is_secure(self): return False def render(self): return str(self.plaintext).strip() @staticmethod def conceal(plaintext, decorate=False, encoding=None, **kwargs): try: import scrypt except ImportError: scrypt_not_installed() encoding = encoding if encoding else get_setting('encoding') plaintext = str(plaintext).encode(encoding) encrypted = scrypt.encrypt( plaintext, get_setting('user_key'), maxtime=0.25 ) encoded = b2a_base64(encrypted).rstrip().decode('ascii') if decorate: return decorate_concealed('Scrypt', encoded) else: return encoded @staticmethod def reveal(ciphertext, encoding=None): try: import scrypt except ImportError: scrypt_not_installed() encrypted = a2b_base64(ciphertext) return scrypt.decrypt(encrypted, get_setting('user_key'))