PK!yypw/__init__.pyfrom __future__ import absolute_import, division, print_function from .store import Store, Entry __version__ = "0.12.0" PK!НPPpw/__main__.py#!/usr/bin/env python from __future__ import absolute_import, division, print_function from functools import partial import os, os.path, random, signal, string, sys import click from . import __version__, Store, _gpg class Mode(object): COPY = "Mode.COPY" ECHO = "Mode.ECHO" RAW = "Mode.RAW" def default_path(): return os.environ.get("PW_PATH") or click.get_app_dir("passwords.pw.asc") style_match = partial(click.style, fg="yellow", bold=True) style_error = style_password = partial(click.style, fg="red", bold=True) style_success = partial(click.style, fg="green", bold=True, reverse=True) def highlight_match(pattern, str): return style_match(pattern).join(str.split(pattern)) if pattern else str RANDOM_PASSWORD_DEFAULT_LENGTH = 32 RANDOM_PASSWORD_ALPHABET = string.ascii_letters + string.digits @click.command() @click.argument("key_pattern", metavar="[USER@][KEY]", default="") @click.argument("user_pattern", metavar="[USER]", default="") @click.option( "--copy", "-C", "mode", flag_value=Mode.COPY, default=True, help="Display account information, but copy password to clipboard (default mode).", ) @click.option( "--echo", "-E", "mode", flag_value=Mode.ECHO, help="Display account information as well as password in plaintext (alternative mode).", ) @click.option( "--raw", "-R", "mode", flag_value=Mode.RAW, help="Only display password in plaintext (alternative mode).", ) @click.option( "--strict", "-S", "strict_flag", is_flag=True, help="Fail unless precisely a single result has been found.", ) @click.option( "--user", "-U", "user_flag", is_flag=True, help="Copy or display username instead of password.", ) @click.option( "--file", "-f", metavar="PATH", default=default_path(), help="Path to password file.", ) @click.option( "--edit", "edit_subcommand", is_flag=True, help="Launch editor to edit password database and exit.", ) @click.option( "--gen", "gen_subcommand", is_flag=True, help="Generate a random password and exit." ) @click.version_option( version=__version__, message="pw version %(version)s\npython " + sys.version ) @click.pass_context def pw( ctx, key_pattern, user_pattern, mode, strict_flag, user_flag, file, edit_subcommand, gen_subcommand, ): """Search for USER and KEY in GPG-encrypted password file.""" # install silent Ctrl-C handler def handle_sigint(*_): click.echo() ctx.exit(1) signal.signal(signal.SIGINT, handle_sigint) # invoke a subcommand? if gen_subcommand: length = int(key_pattern) if key_pattern else None generate_password(mode, length) return elif edit_subcommand: launch_editor(ctx, file) return # verify that database file is present if not os.path.exists(file): click.echo("error: password store not found at '%s'" % file, err=True) ctx.exit(1) # load database store = Store.load(file) # if no user query provided, split key query according to right-most "@" sign (since usernames are typically email addresses) if not user_pattern: user_pattern, _, key_pattern = key_pattern.rpartition("@") # search database results = store.search(key_pattern, user_pattern) results = list(results) # if strict flag is enabled, check that precisely a single record was found if strict_flag and len(results) != 1: click.echo( "error: multiple or no records found (but using --strict flag)", err=True ) ctx.exit(2) # raw mode? if mode == Mode.RAW: for entry in results: click.echo(entry.user if user_flag else entry.password) return # print results for idx, entry in enumerate(results): # start with key and user line = highlight_match(key_pattern, entry.key) if entry.user: line += ": " + highlight_match(user_pattern, entry.user) # add password or copy&paste sucess message if mode == Mode.ECHO and not user_flag: line += " | " + style_password(entry.password) elif mode == Mode.COPY and idx == 0: try: import pyperclip pyperclip.copy(entry.user if user_flag else entry.password) result = style_success( "*** %s COPIED TO CLIPBOARD ***" % ("USERNAME" if user_flag else "PASSWORD") ) except ImportError: result = style_error('*** PYTHON PACKAGE "PYPERCLIP" NOT FOUND ***') line += " | " + result # add notes if entry.notes: if idx == 0: line += "\n" line += "\n".join(" " + line for line in entry.notes.splitlines()) else: lines = entry.notes.splitlines() line += " | " + lines[0] if len(lines) > 1: line += " (...)" click.echo(line) def launch_editor(ctx, file): """launch editor with decrypted password database""" # do not use EDITOR environment variable (rather force user to make a concious choice) editor = os.environ.get("PW_EDITOR") if not editor: click.echo("error: no editor set in PW_EDITOR environment variables") ctx.exit(1) # verify that database file is present if not os.path.exists(file): click.echo("error: password store not found at '%s'" % file, err=True) ctx.exit(1) # load source (decrypting if necessary) is_encrypted = _gpg.is_encrypted(file) if is_encrypted: original = _gpg.decrypt(file) else: original = open(file, "rb").read() # if encrypted, determine recipient if is_encrypted: recipient = os.environ.get("PW_GPG_RECIPIENT") if not recipient: click.echo( "error: no recipient set in PW_GPG_RECIPIENT environment variables" ) ctx.exit(1) # launch the editor ext = _gpg.unencrypted_ext(file) modified = click.edit( original.decode("utf-8"), editor=editor, require_save=True, extension=ext ) if modified is None: click.echo("not modified") return modified = modified.encode("utf-8") # not encrypted? simply overwrite file if not is_encrypted: with open(file, "wb") as fp: fp.write(modified) return # otherwise, the process is somewhat more complicated _gpg.encrypt(recipient=recipient, dest_path=file, content=modified) def generate_password(mode, length): """generate a random password""" # generate random password r = random.SystemRandom() length = length or RANDOM_PASSWORD_DEFAULT_LENGTH password = "".join(r.choice(RANDOM_PASSWORD_ALPHABET) for _ in range(length)) # copy or echo generated password if mode == Mode.ECHO: click.echo(style_password(password)) elif mode == Mode.COPY: try: import pyperclip pyperclip.copy(password) result = style_success("*** PASSWORD COPIED TO CLIPBOARD ***") except ImportError: result = style_error('*** PYTHON PACKAGE "PYPERCLIP" NOT FOUND ***') click.echo(result) elif mode == Mode.RAW: click.echo(password) if __name__ == "__main__": pw(prog_name="pw") PK!YtMM pw/_gpg.pyfrom __future__ import absolute_import, division, print_function import os.path import subprocess from typing import List, Optional, cast _HAS_ARMOR = {".gpg": False, ".asc": True} _EXTENSIONS = _HAS_ARMOR.keys() _OVERRIDE_HOMEDIR = None # type: Optional[str] # useful for unit tests def is_encrypted(path: str) -> bool: _, ext = os.path.splitext(path) return ext in _EXTENSIONS def has_armor(path: str) -> bool: _, ext = os.path.splitext(path) if ext not in _EXTENSIONS: raise ValueError("File extension not recognized as encrypted (%r)." % ext) return _HAS_ARMOR[ext] def unencrypted_ext(path: str) -> str: root, ext = os.path.splitext(path) if ext in _EXTENSIONS: _, ext = os.path.splitext(root) return ext def _base_args() -> List[str]: binary = os.environ.get("PW_GPG", "gpg") args = [binary, "--use-agent", "--quiet", "--batch", "--yes"] if _OVERRIDE_HOMEDIR is not None: args += ["--homedir", _OVERRIDE_HOMEDIR] return args def decrypt(path: str) -> bytes: args = ["--decrypt", path] return cast(bytes, subprocess.check_output(_base_args() + args)) def encrypt(recipient: str, dest_path: str, content: bytes) -> None: args = ["--encrypt"] if has_armor(dest_path): args += ["--armor"] args += ["--recipient", recipient, "--output", dest_path] popen = subprocess.Popen( _base_args() + args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout, stderr = popen.communicate(content) assert popen.returncode == 0, stderr PK!_Ct33 pw/store.pyfrom __future__ import absolute_import, division, print_function from collections import namedtuple from io import StringIO from shlex import shlex from typing import List, Iterable from . import _gpg Entry = namedtuple("Entry", ["key", "user", "password", "notes"]) def _normalized_key(key: str) -> str: return key.replace(" ", "_").lower() class Store: """Password store.""" def __init__(self, path: str, entries: Iterable[Entry]) -> None: # normalize keys self.entries = [e._replace(key=_normalized_key(e.key)) for e in entries] self.path = path def search(self, key_pattern: str, user_pattern: str) -> List[Entry]: """Search database for given key and user pattern.""" # normalize key key_pattern = _normalized_key(key_pattern) # search results = [] for entry in self.entries: if key_pattern in entry.key and user_pattern in entry.user: results.append(entry) # sort results according to key (stability of sorted() ensures that the order of accounts for any given key remains untouched) return sorted(results, key=lambda e: e.key) @staticmethod def load(path: str) -> "Store": """Load password store from file.""" # load source (decrypting if necessary) if _gpg.is_encrypted(path): src_bytes = _gpg.decrypt(path) else: src_bytes = open(path, "rb").read() src = src_bytes.decode("utf-8") # parse database source ext = _gpg.unencrypted_ext(path) assert ext not in [ ".yml", ".yaml", ], "YAML support was removed in version 0.12.0" entries = _parse_entries(src) return Store(path, entries) class SyntaxError(Exception): def __init__(self, lineno: int, line: str, reason: str) -> None: super(SyntaxError, self).__init__( "line %s: %s (%r)" % (lineno + 1, reason, line) ) _EXPECT_ENTRY = "expecting entry" _EXPECT_ENTRY_OR_NOTES = "expecting entry or notes" def _parse_entries(src: str) -> List[Entry]: entries = [] # type: List[Entry] state = _EXPECT_ENTRY for lineno, line in enumerate(src.splitlines()): # empty lines are skipped (but also terminate the notes section) sline = line.strip() if not sline or line.startswith("#"): state = _EXPECT_ENTRY continue # non-empty line with leading spaces is interpreted as a notes line if line[0] in [" ", "\t"]: if state != _EXPECT_ENTRY_OR_NOTES: raise SyntaxError(lineno, line, state) # add line of notes notes = entries[-1].notes if notes: notes += "\n" notes += sline entries[-1] = entries[-1]._replace(notes=notes) continue # otherwise, parse as an entry sio = StringIO(line) lexer = shlex(sio, posix=True) # type: ignore lexer.whitespace_split = True try: key = lexer.get_token() except ValueError as e: raise SyntaxError(lineno, line, str(e)) key = key.rstrip(":") assert key try: user = lexer.get_token() except ValueError as e: raise SyntaxError(lineno, line, str(e)) try: password = lexer.get_token() except ValueError as e: raise SyntaxError(lineno, line, str(e)) if not user and not password: raise SyntaxError(lineno, line, state) if not password: password = user user = notes = u"" else: password = password notes = sio.read().strip() entries.append(Entry(key, user, password, notes)) state = _EXPECT_ENTRY_OR_NOTES return entries PK!H'%$pw-0.12.0.dist-info/entry_points.txtN+I/N.,()*(-(׋M̋*(PK!*''pw-0.12.0.dist-info/LICENSECopyright (c) 2010-2014 Michael Walter 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. PK!HڽTUpw-0.12.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!HjofTpw-0.12.0.dist-info/METADATAU]o6}篸C]3I,M`imN!`ZR$GRvwYу-_{?a"Ks#Y4]u|&ma5H u6` Vx2TG`fEElu~%Cya~!BQ} t- Ԟ` q.ZBt9FHCpW]߭tWM5(?Hz) yF{*&Wpf3K2Oynh+jH&z.y 9J1N~F4Qd][tg#N} .2P[)As=imf{AwJU$z'gU{}.FxN1εtsM}-QKӧdEvnSo{U?qOZkuƮc3 61>o(OQsIl6[TG1OF'pw;yw_ݏ{`qm t yRlđ c.Y% ´@ѡ6@nƸrs#A Ѫ)GHEmaJ-?BxAR4&pbՅVk(@bD&,Bb@wZ#Q q@ڋ֔Q$Z]&Hzt]Ji4MVHeNYdD f4,"AϦ"Ա%2-iK:aҶwH*JDIKuzMjP#l5*Cpp.w^uH(VI-#DhVI@T[^kpPK!H }Spw-0.12.0.dist-info/RECORDmɎ@{? Ur .,-…H#BQ,