Coverage for pass_audit/passwordstore.py: 96%
114 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-16 11:35 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-16 11:35 +0000
1# -*- encoding: utf-8 -*-
2# pass audit - Password Store Extension (https://www.passwordstore.org/)
3# Copyright (C) 2018-2022 Alexandre PUJOL <alexandre@pujol.io>.
4#
6import os
7import shutil
8from subprocess import Popen, PIPE # nosec
9from pathlib import Path
12class PasswordStoreError(Exception):
13 """Error in the execution of password store."""
16class PasswordStore():
17 """Simple Password Store wrapper for python.
19 Based on the PasswordStore class from pass-import.
20 See https://github.com/roddhjav/pass-import for more information.
22 """
24 def __init__(self, prefix=None):
25 self._binary = shutil.which('pass')
26 self._gpgbinary = shutil.which('gpg2') or shutil.which('gpg')
27 self.env = dict(**os.environ)
28 self._setenv('PASSWORD_STORE_DIR')
29 self._setenv('PASSWORD_STORE_KEY')
30 self._setenv('PASSWORD_STORE_GIT', 'GIT_DIR')
31 self._setenv('PASSWORD_STORE_GPG_OPTS')
32 self._setenv('PASSWORD_STORE_X_SELECTION', 'X_SELECTION')
33 self._setenv('PASSWORD_STORE_CLIP_TIME', 'CLIP_TIME')
34 self._setenv('PASSWORD_STORE_UMASK')
35 self._setenv('PASSWORD_STORE_GENERATED_LENGTH', 'GENERATED_LENGTH')
36 self._setenv('PASSWORD_STORE_CHARACTER_SET', 'CHARACTER_SET')
37 self._setenv('PASSWORD_STORE_CHARACTER_SET_NO_SYMBOLS',
38 'CHARACTER_SET_NO_SYMBOLS')
39 self._setenv('PASSWORD_STORE_ENABLE_EXTENSIONS')
40 self._setenv('PASSWORD_STORE_EXTENSIONS_DIR', 'EXTENSIONS')
41 self._setenv('PASSWORD_STORE_SIGNING_KEY')
42 self._setenv('GNUPGHOME')
44 if prefix:
45 self.prefix = prefix
46 if 'PASSWORD_STORE_DIR' not in self.env or self.prefix is None:
47 raise PasswordStoreError("pass prefix unknown")
49 def _setenv(self, var, env=None):
50 """Add var in the environment variables dictionary."""
51 if env is None:
52 env = var
53 if env in os.environ:
54 self.env[var] = os.environ[env]
56 def _call(self, command, data=None, nline=True):
57 """Call to a command."""
58 if isinstance(data, bytes):
59 nline = False
60 with Popen(command, universal_newlines=nline, env=self.env, stdin=PIPE,
61 stdout=PIPE, stderr=PIPE, shell=False) as process:
62 (stdout, stderr) = process.communicate(data)
63 res = process.wait()
64 return res, stdout, stderr
66 def _command(self, arg, data=None, nline=True):
67 """Call to the password manager cli command."""
68 command = [self._binary]
69 command.extend(arg)
70 res, stdout, stderr = self._call(command, data, nline)
71 if res:
72 raise PasswordStoreError(f"{stderr} {stdout}")
73 return stdout
75 @property
76 def prefix(self):
77 """Get password store prefix from PASSWORD_STORE_DIR."""
78 return self.env['PASSWORD_STORE_DIR']
80 @prefix.setter
81 def prefix(self, value):
82 self.env['PASSWORD_STORE_DIR'] = value
84 def list(self, path='', filename='*'):
85 """List the paths in the password store repository."""
86 prefix = os.path.join(self.prefix, path)
87 if os.path.isfile(prefix + '.gpg'):
88 paths = [path]
89 else:
90 paths = []
91 for ppath in Path(prefix).rglob(f'{filename}.gpg'):
92 file = os.sep + str(ppath)[len(self.prefix) + 1:]
93 if f"{os.sep}." not in file:
94 file = os.path.splitext(file)[0][1:]
95 paths.append(file)
96 paths.sort()
97 return paths
99 def show(self, path):
100 """Decrypt path and read the credentials in the password file."""
101 entry = {}
102 entry['group'] = os.path.dirname(path)
103 entry['title'] = os.path.basename(path)
104 try:
105 data = self._command(['show', path]).split('\n')
106 except UnicodeDecodeError:
107 entry['data'] = self._command(['show', path], nline=False)
108 return entry
110 data.pop()
111 if data:
112 line = data.pop(0)
113 if ': ' in line:
114 (key, value) = line.split(': ', 1)
115 entry[key] = value
116 else:
117 entry['password'] = line
118 for line in data:
119 if ': ' in line:
120 (key, value) = line.split(': ', 1)
121 entry[key] = value
122 elif line.startswith('otpauth://'):
123 entry['otpauth'] = line
124 elif 'comments' in entry:
125 entry['comments'] += '\n' + line
126 return entry
128 def exist(self):
129 """Check if the password store is initialized."""
130 return os.path.isfile(os.path.join(self.prefix, '.gpg-id'))
132 def isvalid(self):
133 """Ensure the GPG keyring is usable."""
134 trusted = ['m', 'f', 'u', 'w', 's']
135 with open(os.path.join(self.prefix, '.gpg-id'), 'r') as file:
136 gpgids = file.read().split('\n')
137 gpgids.pop()
139 cmd = [
140 self._gpgbinary,
141 '--with-colons',
142 '--batch',
143 '--list-keys',
144 '--',
145 ]
146 for gpgid in gpgids:
147 res, out, _ = self._call(cmd + [gpgid])
148 if res:
149 return False
150 for line in out.split('\n'):
151 record = line.split(':')
152 if record[0] == 'pub':
153 trust = record[1]
154 if trust not in trusted:
155 return False
157 cmd = [
158 self._gpgbinary,
159 '--with-colons',
160 '--batch',
161 '--list-secret-keys',
162 '--',
163 ]
164 for gpgid in gpgids:
165 res, _, _ = self._call(cmd + [gpgid])
166 if res == 0:
167 return True
168 return False