Source code for avendesora.account

# Account
# API for an account

# License {{{1
# Copyright (C) 2016-2024 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 .browsers import StandardBrowser
from .collection import Collection
from .config import get_setting
from .error import PasswordError
from .obscure import ObscuredSecret
from .preferences import HIDDEN_TOOL_FIELDS, FORBIDDEN_TOOL_FIELDS
from .recognize import Recognizer
from .script import Script
from .secrets import GeneratedSecret
from difflib import get_close_matches
from inform import (
    codicil, conjoin, cull, full_stop, is_collection, is_mapping, is_str,
    join, log, notify, output, warn, indent, render
)
from textwrap import dedent
try:
    from urllib.parse import urlparse
except ImportError:  # pragma: no cover
    from urlparse import urlparse
import re


# Globals {{{1
VECTOR_PATTERN = re.compile(r'\A(\w+)\[(\w+)\]\Z')


# Utilities {{{1
def canonicalize(name):
    return name.replace('-', '_').replace(' ', '_').lower()


def is_forbidden_field(name):
    # return true if a field name is intended only for internal use only
    return name.endswith('_') or name in FORBIDDEN_TOOL_FIELDS


# AccountValue class {{{1
[docs] class AccountValue: """An account value. This is the object returned by :meth:`avendesora.Account.get_value` and :meth:`avendesora.Account.get_values`. It contains information about a single account value. Specifically, it provides the following attributes: *value*, *is_secret*, *name*, *key*, *field*, and *desc*. """ def __init__(self, value, is_secret, name=None, key=None, desc=None): self.value = value self.is_secret = is_secret self.name = name self.key = str(key) if key is not None else key self.field = '.'.join(cull([name, self.key])) self.desc = desc def __str__(self): "Returns value as string." secret = str(self.value) if hasattr(self.value, 'entropy'): entropy = round(self.value.entropy) log('Entropy of {} = {} bits.'.format(self.name, entropy)) return secret
[docs] def render(self, fmts=('{f} ({d}): {v}', '{f}: {v}')): """Return value formatted as a string. Args: fmts (collection of strings): *fmts* contains a sequence of format strings that are tried in sequence. The first one for which all keys are known is used. The possible keys are: | n — name (identifier for the first level of a field) | k — key (identifier for the second level of a field) | f — field (name.key) | d — description | v — value If none work, the value alone is returned. Returns: The value rendered as a string. """ value = str(self.value) if '\n' in value: value = '\n'+indent(dedent(value), get_setting('indent')).strip('\n') if is_str(fmts): fmts = fmts, # build list of arguments, deleting any that is not set args = { k: v for k, v in [ ('f', self.field), ('k', self.key), ('n', self.name), ('d', self.desc), ('v', value) ] if v } # format the arguments, use first format sting that works for fmt in fmts: try: return fmt.format(**args) except KeyError: pass # nothing worked, just return the value return value
def __iter__(self): "Cast AccountValue to a tuple to get value, is_secret, field, and desc." for each in [str(self), self.is_secret, self.field, self.desc]: yield each
# Account class {{{1
[docs] class Account(object): """Class that holds all the information specific to an account. Add desired account information as attributes of the class. """ __NO_MASTER = True # prevents master seed from being added to this base class _accounts = {} # all_loaded_accounts() {{{2 @classmethod def all_loaded_accounts(cls): for sub in cls.__subclasses__(): # do not yield the base StealthAccount if sub != StealthAccount: yield sub for each in sub.all_loaded_accounts(): yield each # get_account() {{{2 @staticmethod def get_account(name): canonical = canonicalize(name) try: return Account._accounts[canonical] except KeyError: # account not found, assemble message giving suggested account names names = Account._accounts.keys() candidates = get_close_matches(canonical, names, 9, 0.6) # do not want to give multiple options all of which are aliases for # the same accounts, so look for up to three unique accounts close_matches = [] for candidate in candidates: account = Account._accounts[candidate] if account not in close_matches: close_matches.append(account) if len(close_matches) >= 3: break # add the aliases to be base account names if available offer = [] for account in close_matches: aliases = getattr(account, 'aliases', None) if aliases: offer.append('{} ({})'.format( account.get_name(), ', '.join(aliases) )) else: offer.append(account.get_name()) # generate the message handling 0, 1, 2 or 3 candidates gracefully msg = 'account not found' if offer: msg = '{}, did you mean:\n {}?'.format( msg, conjoin(offer, sep=',\n ', conj=', or\n ') ) raise PasswordError(full_stop(msg), culprit=name) # get_name() {{{2
[docs] @classmethod def get_name(cls): """Get account name. Returns: Returns the primary account name. This is generally the class name converted to lower case unless it was overridden with the NAME attribute. """ try: return cls.NAME except AttributeError: # consider converting lower to upper case transitions in __name__ to # dashes. return cls.__name__.lower()
# get_seed() {{{2 @classmethod def get_seed(cls): try: return cls.account_seed except AttributeError: return cls.get_name() # override_master() {{{2 @classmethod def request_seed(cls): return getattr(cls, '_interactive_seed_', False) # preprocess() {{{2 @classmethod def preprocess(cls, master, fileinfo, seen): # return if this account has already been processed if hasattr(cls, '_file_info_'): return # account has already been processed # add fileinfo cls._file_info_ = fileinfo # dedent any string attributes for k, v in cls.__dict__.items(): if is_str(v) and '\n' in v: setattr(cls, k, dedent(v)) # add master seed if master and not hasattr(cls, '_%s__NO_MASTER' % cls.__name__): if not hasattr(cls, 'master_seed'): cls.master_seed = master cls._master_source_ = 'file' else: cls._master_source_ = 'account' # convert aliases to a list if hasattr(cls, 'aliases'): aliases = list(Collection(cls.aliases)) cls.aliases = aliases else: aliases = [] # canonicalize names and look for duplicates new = {} account_name = cls.get_name() path = cls._file_info_.path for name in [account_name] + aliases: canonical = canonicalize(name) Account._accounts[canonical] = cls if canonical in seen: if name == account_name: warn('duplicate account name.', culprit=name) else: warn('alias duplicates existing name.', culprit=name) codicil('Seen in %s in %s.' % seen[canonical]) codicil('And in %s in %s.' % (account_name, path)) break else: new[canonical] = (account_name, path) seen.update(new) # this two step approach to updating seen prevents us from # complaining about aliases that duplicate the account name, # or duplicate aliases, both of which are harmless # id_contains() {{{2 @classmethod def id_contains(cls, target): target = target.lower() if target in cls.get_name().lower(): return True try: for alias in cls.aliases: if target in alias.lower(): return True except AttributeError: pass return False # account_contains() {{{2 @classmethod def account_contains(cls, target): if cls.id_contains(target): return True target = target.lower() for key, value in cls.items(): try: if is_collection(value): for k, v in Collection(value).items(): if target in v.lower(): return True elif target in value.lower(): return True except AttributeError: pass return False # recognize() {{{2 @classmethod def recognize(cls, data, verbose): # try the specified recognizers discovery = getattr(cls, 'discovery', ()) for recognizer in Collection(discovery): if isinstance(recognizer, Recognizer): script = recognizer.match(data, cls, verbose) name = getattr(recognizer, 'name', None) if script: yield name, script if discovery: return # If no recognizers specified, just check the urls for url in Collection(cls.get_composite('urls', default=[])): url = str(url) # this is needed because url may be Script url = url if '//' in url else ('//'+url) url_components = urlparse(url) if url_components.scheme: protocol = url_components.scheme else: protocol = get_setting('default_protocol') host = url_components.netloc if host == data.get('host'): if (protocol == data.get('protocol')): if verbose: log(' %s: matches.' % cls.get_name()) yield None, True return else: msg = 'URL matches, but uses wrong protocol.' notify(msg) raise PasswordError(msg, culprit=cls.get_name()) # initialize() {{{2 @classmethod def initialize(cls, interactive_seed=False, stealth_name=None): cls._interactive_seed_ = interactive_seed log('Initializing account:', cls.get_name()) try: if cls.master_seed.is_secure(): if not cls._file_info_.encrypted: warn( 'high value master seed not contained in encrypted', 'account file.', culprit=cls.get_name() ) except AttributeError: pass for bad, good in get_setting('commonly_mistaken_attributes').items(): if hasattr(cls, bad): warn( '{} attribute found,'.format(bad), 'should probably be {}.'.format(good), culprit=cls.get_name() ) # do some more error checking if isinstance(getattr(cls, 'master_seed', None), GeneratedSecret): raise PasswordError( 'master must not be a subclass of GeneratedSecret.', culprit=cls.get_name() ) # fields() {{{2 @classmethod def fields(cls, all=False, sort=False): if sort: fields = sorted(cls.__dict__) else: fields = cls.__dict__ hidden_fields = get_setting('hidden_fields').split() + HIDDEN_TOOL_FIELDS for field in fields: if field.startswith('_') or is_forbidden_field(field): continue if all or field not in hidden_fields: yield field # items() {{{2 @classmethod def items(cls, all=False, sort=False): for field in cls.fields(all, sort): yield field, getattr(cls, field) # get_fields() {{{2
[docs] @classmethod def get_fields(cls, all=False, sort=False): """Iterate through fields. Iterates through the field names. Example:: for name, keys in account.get_fields(): for key, value in account.get_values(name): display(indent( value.render(('{f} ({d}): {v}', '{f}: {v}')) )) Example:: fields = [ account.combine_field(name, key) for name, keys in account.get_fields() for key in keys ] for field in fields: value = account.get_value(field) display(f'{field}: {value!s}') Args: all (bool): If False, ignore the tool fields. sort (bool): If False, use natural sort order. Returns: A pair (2-tuple) that contains both field name and the key names. None is returned for the key names if the field holds a scalar value. """ for field in cls.fields(): value = getattr(cls, field) if is_collection(value): yield field, Collection(value).keys() else: yield field, [None]
# get_scalar() {{{2
[docs] @classmethod def get_scalar(cls, name, key=None, default=False): """Get field value given a field name and key. A lower level interface than *get_value()* that given a name and perhaps a key returns a scalar value. Also takes an optional default value that is returned if the value is not found. Unlike *get_value()*, the actual value is returned, not a object that contains multiple facets of the value. The *name* is the field name, and the *key* would identity which value is desired if the field is a composite. If default is False, an error is raised if the value is not present, otherwise the default value itself is returned. Args: name (str): The name of the field. key (str or int) The key for the desired value (should be None for scalar values). default: The value to return if the requested value is not available. Returns: The requested value. """ value = getattr(cls, name, None) if value is None: if name == 'NAME': return cls.get_name() if default is False: raise PasswordError( 'field not found.', culprit=(cls.get_name(), cls.combine_field(name, key)) ) return default if key is None: if is_collection(value): choices = {} for k, v in Collection(value).items(): try: desc = v.get_description() except AttributeError: desc = None if desc: choices[' %s: %s' % (k, desc)] = k else: choices[' %s:' % k] = k raise PasswordError( 'composite value found, need key. Choose from:', *sorted(choices.keys()), sep = '\n', culprit=(cls.get_name(), cls.combine_field(name, key)), is_collection = True, collection = choices ) else: try: if is_collection(value): value = value[key] else: warn( 'not a composite value, key ignored.', culprit = (cls.get_name(), name) ) key = None except (IndexError, KeyError, TypeError): raise PasswordError( 'key not found.', culprit=(cls.get_name(), cls.combine_field(name, key)) ) # initialize the value if needed try: if hasattr(value, "initialize"): value.initialize(cls, name, key) except PasswordError as e: e.reraise(culprit=e.get_culprit((name, key))) return value
# is_secret() {{{2 @classmethod def is_secret(cls, name, key=None): value = cls.__dict__.get(name) if isinstance(value, Script): str(value) # side effect of convert to string is computing is_secret return value.is_secret if key is None: try: return value.is_secret except AttributeError: return isinstance(value, (GeneratedSecret, ObscuredSecret)) else: try: try: return value[key].is_secret except AttributeError: return isinstance(value[key], (GeneratedSecret, ObscuredSecret)) except (IndexError, KeyError, TypeError): raise PasswordError( 'not found.', culprit=cls.combine_field(name, key) ) # split_field() {{{2 @classmethod def split_field(cls, field): # Account fields can either be scalars or composites (vectors or # dictionaries). This function takes a string or tuple (field) that the # user provides to specify which account value they wish and splits it # into a name and a key. If the field is a scalar, the key will be # None. Users request a value using one of the following forms: # True: use default name # field: scalar value (key=None) # index: questions (field->'questions', key=index) # field[index] or field.index: for vector value # field[key] or field.key: for dictionary value # handle empty field if field is True or field is False or field == '': field = None if field is None: field = cls.get_scalar('default', default=None) # convert field into integer if possible try: field = int(field) except (ValueError, TypeError): pass # separate field into name and key if type(field) is tuple: # split field if given as a tuple if len(field) == 1: name, key = field[0], None elif len(field) == 2: name, key = field[0], field[1] else: raise PasswordError('too many values.', culprit=field) elif is_str(field): # split field if given in the form: name[key] match = VECTOR_PATTERN.match(field) if match: name, key = match.groups() else: # split field if given in the form: name.key try: name, key = field.split('.') except ValueError: # must be simple name name, key = field, None elif field is None: # handle defaulting defaults = get_setting('default_field').split() for default in defaults: if hasattr(cls, default): name, key = default, None break else: raise PasswordError( 'no default available, you must request a specific value.', culprit=cls.get_name() ) elif type(field) is int: name, key = get_setting('default_vector_field'), int(field) else: raise PasswordError('invalid field.', culprit=field) # look up name and key return cls.find_field(name, key) # find_field() {{{2 @classmethod def find_field(cls, name, key=None): # look up field name while ignoring case and treating - and _ as same names = { canonicalize(n): n for n in cls.__dict__.keys() if not is_forbidden_field(n) } try: name = names[canonicalize(name)] except KeyError: raise PasswordError('field not found.', culprit=name) # name is now the true field name, now resolve key if key is None: return name, None try: return name, int(key) except ValueError: pass try: value = getattr(cls, name) keys = { canonicalize(n): n for n in value.keys() } try: key = keys[canonicalize(key)] except KeyError: raise PasswordError('key not found.', culprit=key) except AttributeError: key = None return name, key # combine_field() {{{2 @classmethod def combine_field(cls, name, key=None): # Inverse of split_field(). if key is None: return name else: return '%s.%s' % (name, key) # get_value() {{{2
[docs] @classmethod def get_value(cls, field=None): """Get account value. Return value from the account given a user friendly identifier or script. User friendly identifiers include: | *None*: value of default field | *name*: scalar value | *name.key* or *name[key]*: | member of a dictionary or array | key is string for dictionary, integer for array Scripts are simply strings with embedded attributes. Ex: *'username: {username}, password: {passcode}'* Args: field (str): Field identifier or script. Returns: :class:`avendesora.AccountValue`: the desired value. """ # get default if field was not given if not field: field = cls.get_scalar('default', default=None) # determine whether field is actually a script is_script = is_str(field) and '{' in field and '}' in field if is_script: # run the script script = Script(field) script.initialize(cls) value = str(script) is_secret = script.is_secret name = key = desc = None else: name, key = cls.split_field(field) value = cls.get_scalar(name, key) is_secret = cls.is_secret(name, key) try: desc = value.get_description() except AttributeError: desc = None return AccountValue(value, is_secret, name, key, desc)
# get_values() {{{2
[docs] @classmethod def get_values(cls, name): """Iterate through the values for a field. Args: name (str): The name of the field. Returns: Returns a pair (2-tuple) that contains the key and the value given as an :class:`avendesora.AccountValue` for each of the values. If the value is a scalar, the key is None. """ name, key = cls.find_field(name) value = getattr(cls, name, None) if value is None: if name == 'NAME': value = cls.get_name() else: return values = Collection(value, splitter=False) for key, val in values.items(): value = cls.get_scalar(name, key) is_secret = cls.is_secret(name, key) try: desc = value.get_description() except AttributeError: desc = None yield key, AccountValue(value, is_secret, name, key, desc)
# get_composite() {{{2
[docs] @classmethod def get_composite(cls, name, default=None): """Get field value given a field name. A lower level interface than *get_value()* that given a name returns the value of the associated field, which may be a scalar (string or integer) or a composite (array of dictionary). Unlike *get_value()*, the actual value is returned, not a object that contains multiple facets of the value. Args: name (str): The name of the field. Returns: The requested value. """ value = getattr(cls, name, None) if value is None: if name == 'NAME': return cls.get_name() return default if is_collection(value): if is_mapping(value): # a dictionary or dictionary-like object result = {} for key in value.keys(): v = cls.get_scalar(name, key) if isinstance(v, GeneratedSecret) or isinstance(v, ObscuredSecret): v = str(v) result[key] = v else: result = [] for index in range(len(value)): v = cls.get_scalar(name, index) if isinstance(v, GeneratedSecret) or isinstance(v, ObscuredSecret): v = str(v) result.append(v) else: result = cls.get_scalar(name) if isinstance(result, GeneratedSecret) or isinstance(result, ObscuredSecret): result = str(result) return result
# write_summary() {{{2 @classmethod def write_summary(cls, all=False, sort=False): # present all account values that are not explicitly secret to the user label_color = get_setting('_label_color') highlight_color = get_setting('_highlight_color') def fmt_field(name, value='', key=None, level=0): hl = False # resolve values if isinstance(value, Script): hl = True value = value.script elif cls.is_secret(name, key): reveal = "reveal with: {}".format(highlight_color(join( 'avendesora', 'value', cls.get_name(), cls.combine_field(name, key) ))) value = ', '.join(cull([value.get_description(), reveal])) elif isinstance(value, (GeneratedSecret, ObscuredSecret)): v = cls.get_scalar(name, key) value = ', '.join(cull([value.get_description(), str(v)])) else: value = str(value) # format values if '\n' in value: value = indent(dedent(value), get_setting('indent')).strip('\n') sep = '\n' elif value: sep = ' ' else: sep = '' if hl: value = highlight_color(value) name = str(name).replace('_', ' ') leader = level * get_setting('indent') return indent( label_color((name if key is None else str(key)) + ':') + sep + value, leader ) # preload list with the names associated with this account names = [cls.get_name()] + getattr(cls, 'aliases', []) lines = [fmt_field('names', ', '.join(names))] for key, value in cls.items(all=all, sort=sort): if is_collection(value): lines.append(fmt_field(key)) for k, v in Collection(value).items(): lines.append(fmt_field(key, v, k, level=1)) else: lines.append(fmt_field(key, value)) output(*lines, sep='\n') @classmethod def extract(cls, value, name, key=None): "Generate all secrets in an account value" if not is_collection(value): if hasattr(value, 'initialize'): value.initialize(cls, name, key) return value try: return {k: cls.extract(v, name, k) for k, v in value.items()} except AttributeError: return [cls.extract(v, name, i) for i, v in enumerate(value)] # archive() {{{2 @classmethod def archive(cls): """Return all account fields along with their values Used when creating the accounts archive. Returns: A dictionary containing all account fields with all secrets included. """ return { k: cls.extract(v, k) for k, v in cls.items(True) if k not in ['master_seed', 'account_seed'] } # export() {{{2
[docs] @classmethod def export(cls, fold_level=1): # return all account fields along with their values """Return all account fields along with their values as a dictionary Used when exporting accounts. If fold_level is truthy, it should be a positive integer that indicates the fold level for each account. Returns: A string that contains all account fields with all secrets included formatted as an Account class. """ values = [ '{} = {}'.format(k, render(cls.extract(v, k), level=1)) for k, v in cls.items(True) if k not in ['master_seed', 'account_seed'] ] return dedent(''' class {name}(Account):{fold} {values} ''').format( fold = ' # {' + '{' + '{' + str(fold_level) if fold_level else '', name = cls.__name__, values = '\n '.join(values), ).strip()
# open_browser() {{{2 @classmethod def open_browser(cls, key=None, browser_name=None, list_urls=False): if not browser_name: browser_name = cls.get_scalar('browser', default=None) browser = StandardBrowser(browser_name) label_color = get_setting('_label_color') highlight_color = get_setting('_highlight_color') # get the urls from the urls attribute primary_urls = getattr(cls, 'urls', []) if type(primary_urls) != dict: if is_str(primary_urls): primary_urls = primary_urls.split() primary_urls = {None: primary_urls} if primary_urls else {} # get the urls from the url recognizers discovery = getattr(cls, 'discovery', ()) urls = {} for each in Collection(discovery): urls.update(each.all_urls()) # combine, primary_urls must be added to urls, so they dominate urls.update(primary_urls) if list_urls: default = getattr(cls, 'default_url', None) for name, url in urls.items(): if is_collection(url): url = list(Collection(url))[0] if name == default: url += highlight_color(' [default]') if not name: name = '' elif not name: continue output(label_color('{:>24s}:'.format(name)), url) return # select the urls keys = list(urls.keys()) if not key: key = getattr(cls, 'default_url', None) if not key and keys and len(keys) == 1: key = keys[0] try: urls = urls[key] except KeyError: if keys: if key: msg = 'unknown key, choose from {}.' else: msg = 'key required, choose from {}.' raise PasswordError( msg.format(conjoin(repr(k) for k in keys)), culprit=key ) else: if key: raise PasswordError( 'keys are not supported with urls on this account.', culprit=key ) else: raise PasswordError('no url available.') # open the url urls = Collection(urls) url = list(urls)[0] # use the first url specified browser.run(url) # has_field() {{{2 @classmethod def has_field(cls, name): return name in dir(cls) # get_username() {{{2
[docs] @classmethod def get_username(cls): """Get the username. Like *get_value()*, but tries the *credential_ids* in order and returns the first found. *credential_ids* is an Avendesora configuration setting that by default is *username* and *email*. Returns: The username or email address. """ identities = Collection(get_setting('credential_ids')) for identity in identities: try: return cls.get_value(identity) except PasswordError: pass
# get_passcode() {{{2
[docs] @classmethod def get_passcode(cls): """Get the passcode. Like *get_value()*, but tries the *credential_secrets* in order and returns the first found. *credential_secrets* is an Avendesora configuration setting that by default is *password*, *passphrase*, and *passcode*. Returns: The passcode. """ secrets = Collection(get_setting('credential_secrets')) for passcode in secrets: try: return cls.get_value(passcode) except PasswordError: pass
# StealthAccount class {{{1 class StealthAccount(Account): """Empty account that defines how to generate a secret. With stealth accounts the actual account name is requested directly from the user. Only one attribute is generally given for stealth accounts, which is suitable as a default such as password, passphrase or passcode, and that contains the method for generating the desired secret based on the account name and the master seed in the stealth_accounts file. """ __NO_MASTER = True # prevents master password from being added to this base class # initialize() {{{2 @classmethod def initialize(cls, interactive_seed=False, stealth_name=None): cls._interactive_seed_ = interactive_seed cls._stealth_name = stealth_name log('Initializing stealth account:', cls.get_name()) for key, value in cls.items(): # reset the secrets so they honor stealth_name try: value.reset() except AttributeError: pass @classmethod def get_seed(cls): if cls._stealth_name: # In this case we are running using API rather than running from # command line and the account names was specified to get_account(). return cls._stealth_name # need to handle case where stdin/stdout is not available. # perhaps write generic password getter that supports both gui and tui. # Then have global option that indicates which should be used. # Separate name from seed. Only request seed when generating a password. import getpass try: name = getpass.getpass('account name: ') except EOFError: output() name = '' if not name: warn('null account name.') return name @classmethod def archive(cls): # do not archive stealth accounts pass