441 lines
13 KiB
Python
Executable File
441 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import csv
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
import gnupg
|
|
|
|
from collections import OrderedDict
|
|
|
|
__version__ = '1.1.1'
|
|
|
|
|
|
def stderr(s, *args, **kwargs):
|
|
print(s, *args, file=sys.stderr, **kwargs)
|
|
|
|
|
|
def set_meta(entry, path, grouping_base):
|
|
pure_path = pathlib.PurePath(path)
|
|
group = pure_path.relative_to(grouping_base).parent
|
|
if group.name == '':
|
|
group = ''
|
|
entry['group'] = group
|
|
entry['title'] = pure_path.stem
|
|
|
|
|
|
def set_data(entry, data, exclude, get_fields, get_lines):
|
|
lines = data.splitlines()
|
|
tail = lines[1:]
|
|
entry['password'] = lines[0]
|
|
|
|
filtered_tail = []
|
|
for line in tail:
|
|
for exclude_pattern in exclude:
|
|
if exclude_pattern.search(line):
|
|
break
|
|
else:
|
|
filtered_tail.append(line)
|
|
|
|
matching_indices = set()
|
|
fields = entry.setdefault('fields', {})
|
|
|
|
for i, line in enumerate(filtered_tail):
|
|
for name, pattern in get_fields:
|
|
if name in fields:
|
|
# multiple patterns with same name, we've already found a match
|
|
continue
|
|
match = pattern.search(line)
|
|
if not match:
|
|
continue
|
|
inverse_match = line[0:match.start()] + line[match.end():]
|
|
value = inverse_match.strip()
|
|
fields[name] = value
|
|
matching_indices.add(i)
|
|
break
|
|
|
|
matching_lines = {}
|
|
for i, line in enumerate(filtered_tail):
|
|
for name, pattern, subc in get_lines:
|
|
match = pattern.search(line)
|
|
if not match:
|
|
continue
|
|
matches = matching_lines.setdefault(name, [])
|
|
if subc is not None:
|
|
line = subc.sub('', line)
|
|
matches.append(line)
|
|
matching_indices.add(i)
|
|
break
|
|
for name, matches in matching_lines.items():
|
|
fields[name] = '\n'.join(matches)
|
|
|
|
final_tail = []
|
|
for i, line in enumerate(filtered_tail):
|
|
if i not in matching_indices:
|
|
final_tail.append(line)
|
|
|
|
entry['notes'] = '\n'.join(final_tail).strip()
|
|
|
|
|
|
bwFieldSet = OrderedDict({
|
|
'folder': 'group',
|
|
'favorite': None,
|
|
'type': 'type',
|
|
'name': 'title',
|
|
'notes': 'notes',
|
|
'fields': '__fields',
|
|
'reprompt': 'reprompt',
|
|
'login_uri': 'url',
|
|
'login_username': 'username',
|
|
'login_password': 'password',
|
|
'login_totp': 'otp'
|
|
})
|
|
|
|
header = [key for key, value in bwFieldSet.items()]
|
|
|
|
def write(file, entries, get_fields, get_lines, notes, ignore, usernames, no_degroup):
|
|
get_field_names = set(x[0] for x in get_fields)
|
|
get_line_names = set(x[0] for x in get_lines)
|
|
field_names = get_field_names | get_line_names
|
|
csvw = csv.writer(file, dialect='unix')
|
|
stderr(f"\nWriting data to {file.name}\n")
|
|
csvw.writerow(header)
|
|
for entry in entries:
|
|
if entry['title'] in ignore:
|
|
continue
|
|
columns = []
|
|
fieldIndex = -1
|
|
if entry['group'] not in no_degroup:
|
|
for upat in usernames:
|
|
m = upat.search(entry['title'])
|
|
if m:
|
|
# username match, clean up the entry
|
|
if entry['fields'].get('username') in ['', None]:
|
|
entry['fields']['username'] = m[0]
|
|
else:
|
|
entry['fields']['titleuser'] = m[0]
|
|
oldTitle = entry['title']
|
|
entry['title'] = upat.sub(str(entry['group']), str(entry['title'])).rstrip(":")
|
|
entry['fields']['passEntry'] = '{}/{}'.format(entry['group'], oldTitle)
|
|
entry['group'] = ''
|
|
for bwf, pf in bwFieldSet.items():
|
|
if pf is None:
|
|
columns.append('')
|
|
elif pf == 'type':
|
|
if entry['title'] in notes:
|
|
columns.append('note')
|
|
else:
|
|
columns.append('login')
|
|
elif pf == '__fields':
|
|
columns.append('')
|
|
fieldIndex = len(columns) - 1
|
|
pass
|
|
elif pf in ['group', 'title', 'notes', 'password']:
|
|
try:
|
|
columns.append(entry[pf])
|
|
except KeyError:
|
|
columns.append('')
|
|
else:
|
|
try:
|
|
columns.append(entry['fields'][pf])
|
|
del entry['fields'][pf]
|
|
except KeyError:
|
|
columns.append('')
|
|
# get remaining fields
|
|
fields = ""
|
|
for fKey, value in entry['fields'].items():
|
|
fields += "{}: {}\n".format(fKey, value)
|
|
columns[fieldIndex] = fields.rstrip('\n')
|
|
csvw.writerow(columns)
|
|
|
|
|
|
def main(store_path, outfile, grouping_base, gpgbinary, use_agent, encodings,
|
|
exclude, get_fields, get_lines, notes, ignore, usernames, no_degroup):
|
|
entries = []
|
|
failures = []
|
|
path = pathlib.Path(store_path)
|
|
grouping_path = pathlib.Path(grouping_base)
|
|
gpg = gnupg.GPG(gpgbinary=gpgbinary, use_agent=use_agent)
|
|
files = path.glob('**/*.gpg')
|
|
if not path.is_dir():
|
|
if path.is_file():
|
|
files = [path]
|
|
else:
|
|
stderr(f"No such file or directory: {path}")
|
|
sys.exit(1)
|
|
for file in files:
|
|
stderr(f"Processing {file}")
|
|
with open(file, 'rb') as fp:
|
|
decrypted = gpg.decrypt_file(fp)
|
|
if not decrypted.ok:
|
|
err = f"Could not decrypt {file}: {decrypted.status}"
|
|
stderr(err)
|
|
failures.append(err)
|
|
continue
|
|
for i, encoding in enumerate(encodings):
|
|
try:
|
|
# decrypted.data is bytes
|
|
decrypted_data = decrypted.data.decode(encoding)
|
|
except Exception as e:
|
|
stderr(f"Could not decode {file} with encoding {encoding}: {e}")
|
|
continue
|
|
if i > 0:
|
|
# don't log if the first encoding worked
|
|
stderr(f"Decoded {file} with encoding {encoding}")
|
|
break
|
|
else:
|
|
err = "Could not decode {}, see messages above for more info.".format(file)
|
|
failures.append(err)
|
|
continue
|
|
entry = {}
|
|
set_meta(entry, file, grouping_path)
|
|
set_data(entry, decrypted_data, exclude, get_fields, get_lines)
|
|
entries.append(entry)
|
|
if failures:
|
|
stderr("\nGot errors while processing files:")
|
|
for err in failures:
|
|
stderr(err)
|
|
if not entries:
|
|
stderr("\nNothing to write.")
|
|
sys.exit(1)
|
|
write(outfile, entries, get_fields, get_lines, notes, ignore, usernames, no_degroup)
|
|
|
|
|
|
def parse_args(args=None):
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
'store_path',
|
|
metavar='STOREPATH',
|
|
type=str,
|
|
help="path to the password-store to export",
|
|
)
|
|
|
|
parser.add_argument(
|
|
'outfile',
|
|
metavar='OUTFILE',
|
|
type=argparse.FileType('w'),
|
|
help="file to write exported data to, use - for stdout",
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-b', '--base',
|
|
metavar='path',
|
|
type=str,
|
|
help="path to use as base for grouping passwords",
|
|
dest='base_path'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-g', '--gpg',
|
|
metavar='executable',
|
|
type=str,
|
|
default="gpg",
|
|
help="path to the gpg binary you wish to use (default: '%(default)s')",
|
|
dest='gpgbinary'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-a', '--use-agent',
|
|
action='store_true',
|
|
default=False,
|
|
help="ask gpg to use its auth agent",
|
|
dest='use_agent'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--encodings',
|
|
metavar='encodings',
|
|
type=str,
|
|
default="utf-8",
|
|
help=(
|
|
"comma-separated text encodings to try, in order, when decoding"
|
|
" gpg output (default: '%(default)s')"
|
|
),
|
|
dest='encodings'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-e', '--exclude',
|
|
metavar='pattern',
|
|
action='append',
|
|
type=str,
|
|
default=['^autotype:'],
|
|
help=(
|
|
"regexp for lines which should not be exported, can be specified"
|
|
" multiple times"
|
|
),
|
|
dest='exclude'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-f', '--get-field',
|
|
metavar=('name', 'pattern'),
|
|
action='append',
|
|
nargs=2,
|
|
type=str,
|
|
default=[['pin', '^(phone)?pin: '], ['username', '^(user(name)?|login): ']],
|
|
help=(
|
|
"a name and a regexp, the part of the line matching the regexp"
|
|
" will be removed and the remaining line will be added to a field"
|
|
" with the chosen name. only one match per password, matching"
|
|
" stops after the first match"
|
|
),
|
|
dest='get_fields'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-l', '--get-line',
|
|
metavar=('name', 'pattern', 'replace'),
|
|
action='append',
|
|
nargs=3,
|
|
type=str,
|
|
default=[['otp', 'otpauth://', ''], ['url', 'https?://', '^url: *']],
|
|
help=(
|
|
"a name and a regexp for which all lines that match are included"
|
|
" in a field with the chosen name. replace is a regex to remove matches of from entry."
|
|
),
|
|
dest='get_lines'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--version',
|
|
action='version',
|
|
version='%(prog)s ' + __version__
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-n', '--note',
|
|
metavar='title',
|
|
action='append',
|
|
type=str,
|
|
default=[],
|
|
help=(
|
|
"a title of entry that should be added as a note, "
|
|
"can be specified multiple times"
|
|
),
|
|
dest='notes'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-i', '--ignore',
|
|
metavar='title',
|
|
action='append',
|
|
type=str,
|
|
default=[],
|
|
help=(
|
|
"a title of entry that should be ignored, "
|
|
"can be specified multiple times"
|
|
),
|
|
dest='ignore'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-u', '--username',
|
|
metavar='title',
|
|
action='append',
|
|
type=str,
|
|
default=[],
|
|
help=(
|
|
"a regex matching a username in title, "
|
|
"can be specified multiple times. any match will be replaced with the group name"
|
|
),
|
|
dest='usernames'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-D', '--no-degroup',
|
|
metavar='title',
|
|
action='append',
|
|
type=str,
|
|
default=[],
|
|
help=(
|
|
"a list of groups not to perform username degrouping on, "
|
|
"can be specified multiple times."
|
|
),
|
|
dest='no_degroup'
|
|
)
|
|
|
|
return parser.parse_args(args)
|
|
|
|
|
|
def compile_regexp(pattern):
|
|
try:
|
|
regexp = re.compile(pattern, re.I)
|
|
except re.error as e:
|
|
escaped = pattern.replace("'", "\\'")
|
|
stderr(f"Could not compile pattern '{escaped}', {e.msg} at position {e.pos}")
|
|
return None
|
|
return regexp
|
|
|
|
|
|
def cli():
|
|
parsed = parse_args()
|
|
|
|
failed = False
|
|
exclude_patterns = []
|
|
for pattern in parsed.exclude:
|
|
regexp = compile_regexp(pattern)
|
|
if not regexp:
|
|
failed = True
|
|
exclude_patterns.append(regexp)
|
|
|
|
get_fields = []
|
|
for name, pattern in parsed.get_fields:
|
|
regexp = compile_regexp(pattern)
|
|
if not regexp:
|
|
failed = True
|
|
get_fields.append((name, regexp))
|
|
|
|
get_lines = []
|
|
for name, pattern, subs in parsed.get_lines:
|
|
regexp = compile_regexp(pattern)
|
|
if not regexp:
|
|
failed = True
|
|
if subs == '':
|
|
subc = None
|
|
else:
|
|
subc = compile_regexp(subs)
|
|
get_lines.append((name, regexp, subc))
|
|
|
|
username_patterns = []
|
|
for pattern in parsed.usernames:
|
|
regexp = compile_regexp(pattern)
|
|
if not regexp:
|
|
failed = True
|
|
username_patterns.append(regexp)
|
|
|
|
if failed:
|
|
sys.exit(1)
|
|
|
|
if parsed.base_path:
|
|
grouping_base = parsed.base_path
|
|
else:
|
|
grouping_base = parsed.store_path
|
|
|
|
encodings = [e for e in parsed.encodings.split(',') if e]
|
|
if not encodings:
|
|
stderr(f"Did not understand '--encodings {parsed.encoding}'")
|
|
sys.exit(1)
|
|
|
|
kwargs = {
|
|
'store_path': parsed.store_path,
|
|
'outfile': parsed.outfile,
|
|
'grouping_base': grouping_base,
|
|
'gpgbinary': parsed.gpgbinary,
|
|
'use_agent': parsed.use_agent,
|
|
'encodings': encodings,
|
|
'exclude': exclude_patterns,
|
|
'get_fields': get_fields,
|
|
'get_lines': get_lines,
|
|
'notes': parsed.notes,
|
|
'ignore': parsed.ignore,
|
|
'usernames': username_patterns,
|
|
'no_degroup': parsed.no_degroup
|
|
}
|
|
|
|
main(**kwargs)
|
|
|
|
if __name__ == '__main__':
|
|
cli()
|