Source code for qpass

# qpass: Frontend for pass (the standard unix password manager).
#
# Author: Peter Odding <peter@peterodding.com>
# Last Change: December 3, 2018
# URL: https://github.com/xolox/python-qpass

"""
Frontend for pass_, the standard unix password manager.

.. _pass: https://www.passwordstore.org/
"""

# Standard library modules.
import fnmatch
import logging
import os
import platform
import re

# External dependencies.
from executor import execute
from executor.contexts import LocalContext
from humanfriendly import Timer, coerce_pattern, format_path, parse_path
from humanfriendly.terminal import HIGHLIGHT_COLOR, ansi_wrap, terminal_supports_colors
from humanfriendly.prompts import prompt_for_choice
from humanfriendly.text import concatenate, format, pluralize, split, trim_empty_lines
from natsort import natsort
from proc.gpg import get_gpg_variables
from property_manager import (
    PropertyManager,
    cached_property,
    clear_property,
    mutable_property,
    required_property,
    set_property,
)
from verboselogs import VerboseLogger

# Modules included in our package.
from qpass.exceptions import EmptyPasswordStoreError, MissingPasswordStoreError, NoMatchingPasswordError

# Public identifiers that require documentation.
__all__ = (
    "DEFAULT_DIRECTORY",
    "DIRECTORY_VARIABLE",
    "AbstractPasswordStore",
    "PasswordEntry",
    "PasswordStore",
    "QuickPass",
    "__version__",
    "create_fuzzy_pattern",
    "logger",
)

# Semi-standard module versioning.
__version__ = "2.3"

DEFAULT_DIRECTORY = "~/.password-store"
"""
The default password storage directory (a string).

The value of :data:`DEFAULT_DIRECTORY` is normalized using
:func:`~humanfriendly.parse_path()`.
"""

DIRECTORY_VARIABLE = "PASSWORD_STORE_DIR"
"""The environment variable that sets the password storage directory (a string)."""

KEY_VALUE_PATTERN = re.compile(r"^(.+\S):\s+(\S.*)$")
"""A compiled regular expression to recognize "Key: Value" lines."""

# Initialize a logger for this module.
logger = VerboseLogger(__name__)


[docs]class AbstractPasswordStore(PropertyManager): """ Abstract Python API to query passwords managed by pass_. This abstract base class has two concrete subclasses: - The :class:`QuickPass` class manages multiple password stores as one. - The :class:`PasswordStore` class manages a single password store. """ @property def entries(self): """A list of :class:`PasswordEntry` objects.""" raise NotImplementedError()
[docs] @mutable_property(cached=True) def exclude_list(self): """ A list of strings with filename patterns to ignore. The :mod:`fnmatch` module is used for pattern matching. Filenames as well as patterns are normalized to lowercase before pattern matching is attempted. """ return []
[docs] @cached_property def filtered_entries(self): """A list of :class:`PasswordEntry` objects that don't match the exclude list.""" return [ e for e in self.entries if not any(fnmatch.fnmatch(e.name.lower(), p.lower()) for p in self.exclude_list) ]
[docs] def select_entry(self, *arguments): """ Select a password from the available choices. :param arguments: Refer to :func:`smart_search()`. :returns: The name of a password (a string) or :data:`None` (when no password matched the given `arguments`). """ matches = self.smart_search(*arguments) if len(matches) > 1: logger.info("More than one match, prompting for choice ..") labels = [entry.name for entry in matches] return matches[labels.index(prompt_for_choice(labels))] else: logger.info("Matched one entry: %s", matches[0].name) return matches[0]
[docs]class QuickPass(AbstractPasswordStore): """ Python API to query multiple password stores as if they are one. :see also: The :class:`PasswordStore` class to query a single password store. """ repr_properties = ["stores"] """The properties included in the output of :func:`repr()`."""
[docs] @cached_property def entries(self): """A list of :class:`PasswordEntry` objects.""" passwords = [] for store in self.stores: passwords.extend(store.entries) return natsort(passwords, key=lambda e: e.name)
[docs] @mutable_property(cached=True) def stores(self): """A list of :class:`PasswordStore` objects.""" return [PasswordStore()]
[docs]class PasswordStore(AbstractPasswordStore): """ Python API to query a single password store. :see also: The :class:`QuickPass` class to query multiple password stores. """ repr_properties = ["directory", "entries"] """The properties included in the output of :func:`repr()`."""
[docs] @mutable_property(cached=True) def context(self): """ An execution context created using :mod:`executor.contexts`. The value of :attr:`context` defaults to a :class:`~executor.contexts.LocalContext` object with the following characteristics: - The working directory of the execution context is set to the value of :attr:`directory`. - The environment variable given by :data:`DIRECTORY_VARIABLE` is set to the value of :attr:`directory`. :raises: :exc:`.MissingPasswordStoreError` when :attr:`directory` doesn't exist. """ # Make sure the directory exists. self.ensure_directory_exists() # Prepare the environment variables. environment = {DIRECTORY_VARIABLE: self.directory} try: # Try to enable the GPG agent in headless sessions. environment.update(get_gpg_variables()) except Exception: # If we failed then let's at least make sure that the # $GPG_TTY environment variable is set correctly. environment.update(GPG_TTY=execute("tty", capture=True, check=False, tty=True, silent=True)) return LocalContext(directory=self.directory, environment=environment)
@mutable_property(cached=True) def directory(self): """ The pathname of the password storage directory (a string). When the environment variable given by :data:`DIRECTORY_VARIABLE` is set the value of that environment variable is used, otherwise :data:`DEFAULT_DIRECTORY` is used. In either case the resulting directory pathname is normalized using :func:`~humanfriendly.parse_path()`. When you set the :attr:`directory` property, the value you set will be normalized using :func:`~humanfriendly.parse_path()` and the computed value of the :attr:`context` property is cleared. """ return parse_path(os.environ.get(DIRECTORY_VARIABLE, DEFAULT_DIRECTORY))
[docs] @directory.setter def directory(self, value): """Normalize the value of :attr:`directory` when it's set.""" # Normalize the value of `directory'. set_property(self, "directory", parse_path(value)) # Clear the computed values of `context' and `entries'. clear_property(self, "context") clear_property(self, "entries")
[docs] @cached_property def entries(self): """A list of :class:`PasswordEntry` objects.""" timer = Timer() passwords = [] logger.info("Scanning %s ..", format_path(self.directory)) listing = self.context.capture("find", "-type", "f", "-name", "*.gpg", "-print0") for filename in split(listing, "\0"): basename, extension = os.path.splitext(filename) if extension == ".gpg": # We use os.path.normpath() to remove the leading `./' prefixes # that `find' adds because it searches the working directory. passwords.append(PasswordEntry(name=os.path.normpath(basename), store=self)) logger.verbose("Found %s in %s.", pluralize(len(passwords), "password"), timer) return natsort(passwords, key=lambda e: e.name)
[docs] def ensure_directory_exists(self): """ Make sure :attr:`directory` exists. :raises: :exc:`.MissingPasswordStoreError` when the password storage directory doesn't exist. """ if not os.path.isdir(self.directory): msg = "The password storage directory doesn't exist! (%s)" raise MissingPasswordStoreError(msg % self.directory)
[docs]class PasswordEntry(PropertyManager): """:class:`PasswordEntry` objects bind the name of a password to the store that contains the password.""" repr_properties = ["name"] """The properties included in the output of :func:`repr()`.""" @property def context(self): """The :attr:`~PasswordStore.context` of :attr:`store`.""" return self.store.context
[docs] @required_property def name(self): """The name of the password store entry (a string)."""
[docs] @cached_property def password(self): """The password identified by :attr:`name` (a string).""" return self.text.splitlines()[0]
[docs] @required_property def store(self): """The :class:`PasswordStore` that contains the entry."""
[docs] @cached_property def text(self): """The full text of the entry (a string).""" return self.context.capture("pass", "show", self.name)
[docs] def copy_password(self): """Copy the password to the clipboard.""" self.context.execute("pass", "show", "--clip", self.name)
[docs] def format_text(self, include_password=True, use_colors=None, padding=True, filters=()): """ Format :attr:`text` for viewing on a terminal. :param include_password: :data:`True` to include the password in the formatted text, :data:`False` to exclude the password from the formatted text. :param use_colors: :data:`True` to use ANSI escape sequences, :data:`False` otherwise. When this is :data:`None` :func:`~humanfriendly.terminal.terminal_supports_colors()` will be used to detect whether ANSI escape sequences are supported. :param padding: :data:`True` to add empty lines before and after the entry and indent the entry's text with two spaces, :data:`False` to skip the padding. :param filters: An iterable of regular expression patterns (defaults to an empty tuple). If a line in the entry's text matches one of these patterns it won't be shown on the terminal. :returns: The formatted entry (a string). """ # Determine whether we can use ANSI escape sequences. if use_colors is None: use_colors = terminal_supports_colors() # Extract the password (first line) from the entry. lines = self.text.splitlines() password = lines.pop(0).strip() # Compile the given patterns to case insensitive regular expressions # and use them to ignore lines that match any of the given filters. patterns = [coerce_pattern(f, re.IGNORECASE) for f in filters] lines = [l for l in lines if not any(p.search(l) for p in patterns)] text = trim_empty_lines("\n".join(lines)) # Include the password in the formatted text? if include_password: text = "Password: %s\n%s" % (password, text) # Add the name to the entry (only when there's something to show). if text and not text.isspace(): title = " / ".join(split(self.name, "/")) if use_colors: title = ansi_wrap(title, bold=True) text = "%s\n\n%s" % (title, text) # Highlight the entry's text using ANSI escape sequences. lines = [] for line in text.splitlines(): # Check for a "Key: Value" line. match = KEY_VALUE_PATTERN.match(line) if match: key = "%s:" % match.group(1).strip() value = match.group(2).strip() if use_colors: # Highlight the key. key = ansi_wrap(key, color=HIGHLIGHT_COLOR) # Underline hyperlinks in the value. tokens = value.split() for i in range(len(tokens)): if "://" in tokens[i]: tokens[i] = ansi_wrap(tokens[i], underline=True) # Replace the line with a highlighted version. line = key + " " + " ".join(tokens) if padding: line = " " + line lines.append(line) text = "\n".join(lines) text = trim_empty_lines(text) if text and padding: text = "\n%s\n" % text return text
[docs]def create_fuzzy_pattern(pattern): """ Convert a string into a fuzzy regular expression pattern. :param pattern: The input pattern (a string). :returns: A compiled regular expression object. This function works by adding ``.*`` between each of the characters in the input pattern and compiling the resulting expression into a case insensitive regular expression. """ return re.compile(".*".join(map(re.escape, pattern)), re.IGNORECASE)
def is_clipboard_supported(): """ Check whether the clipboard is supported. :returns: :data:`True` if the clipboard is supported, :data:`False` otherwise. """ return platform.system().lower() == "darwin" or bool(os.environ.get("DISPLAY"))