Skip to content

Commit 2cc348b

Browse files
committed
Merge branch 'feature-username-selector'
More flexible selection strategies for usernames (and passwords internally). fixes #8 and fixes #9
2 parents 0d78e41 + 7e242d7 commit 2cc348b

File tree

6 files changed

+430
-28
lines changed

6 files changed

+430
-28
lines changed

README.md

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44

55
[![Debian CI](https://badges.debian.net/badges/debian/testing/pass-git-helper/version.svg)](https://buildd.debian.org/pass-git-helper) [![AUR](https://img.shields.io/aur/version/pass-git-helper.svg)](https://aur.archlinux.org/packages/pass-git-helper/)
66

7-
A [git] credential helper implementation that allows to use [pass] as the credential backend for your git repositories.
7+
A [git] credential helper implementation that allows using [pass] as the credential backend for your git repositories.
88
This is achieved by explicitly defining mappings between hosts and entries in the password store.
99

1010
## Preconditions
1111

1212
GPG must be configured to use a graphical pinentry dialog.
13-
The shell cannot be used due to the interaction required by [git]
13+
The shell cannot be used due to the interaction required by [git].
1414

1515
## Installation
1616

@@ -31,7 +31,7 @@ Ensure that `~/.local/bin` is in your `PATH` for the single-user installation.
3131
Create the file `~/.config/pass-git-helper/git-pass-mapping.ini`.
3232
This file uses ini syntax to specify the mapping of hosts to entries in the passwordstore database.
3333
Section headers define patterns which are matched against the host part of a URL with a git repository.
34-
Matching supports wildcards (using the python [fnmatch module](https://docs.python.org/3.4/library/fnmatch.html)).
34+
Matching supports wildcards (using the python [fnmatch module](https://docs.python.org/3.7/library/fnmatch.html)).
3535
Each section needs to contain a `target` entry pointing to the entry in the password store with the password (and optionally username) to use.
3636

3737
Example:
@@ -81,25 +81,71 @@ target=git-logins/${host}
8181
```
8282
The above configuration directive will lead to any host that did not match any previous section in the ini file to being looked up under the `git-logins` directory in your passwordstore.
8383

84-
## Passwordstore Layout
84+
### DEFAULT section
85+
86+
Defaults suitable for all entries of the mapping file can be specified in a special section of the configuration file named `[DEFAULT]`.
87+
Everything configure in this section will automatically be available for all further entries in the file, but can be overriden there, too.
88+
89+
## Passwordstore Layout and Data Extraction
90+
91+
### Password
8592

8693
As usual with [pass], this helper assumes that the password is contained in the first line of the passwordstore entry.
87-
Additionally, if a second line is present, this line is interpreted as the username and also returned back to the git process invoking this helper.
88-
In case you use markers at the start of lines to identify what is contained in this line, e.g. like `Username: fooo`, the options `skip_username` and `skip_password` can be defined in each mapping to skip the given amount of characters from the beginning of the respective line.
89-
Additionally, global defaults can be configured via the `DEFAULT` section:
94+
Though uncommon, it is possible to strip a prefix from the data of the first line (such as `password:` by specifying an amount of characters to leave out in the `skip_password` field for an entry or also in the `[DEFAULT]` section to apply for all entries:
95+
9096
```ini
9197
[DEFAULT]
92-
# this is actually the default
93-
skip_password=0
94-
# Lenght of "Username: "
95-
skip_username=10
98+
# length of "password: "
99+
skip_password=10
96100

97101
[somedomain]
98-
target=special/somedomain
99-
# somehow this entry does not have a prefix for the username
100-
skip_username=0
102+
# for some reasons, this entry doesn't have a password prefix
103+
skip_password=0
104+
target=special/noprefix
101105
```
102106

107+
### Username
108+
109+
`pass-git-helper` can also provide the username necessary for authenticating at a server.
110+
In contrast to the password, no clear convention exists how username information is stored in password entries.
111+
Therefore, multiple strategies to extract the username are implemented and can be selected globally for the whole passwordstore in the `[DEFAULT]` section, or individually for certain entries using the `username_extractor` key:
112+
113+
```ini
114+
[DEFAULT]
115+
username_extractor=regex_search
116+
regex_username=^user: (.*)$
117+
118+
[differingdomain.com]
119+
# use a fixed line here instead of a regex search
120+
username_extractor=specific_line
121+
line_username=1
122+
```
123+
124+
The following strategies can be configured:
125+
126+
#### Strategy "specific_line" (default)
127+
128+
Extracts the data from a line indexed by its line number.
129+
Optionally a fixed-length prefix can be stripped before returning the line contents.
130+
131+
Configuration:
132+
* `line_username`: Line number containing the username, **0-based**. Default: 1 (second line)
133+
* `skip_username`: Number of characters to skip at the beginning of the line, for instance to skip a `user: ` prefix. Similar to `skip_password`. Default: 0.
134+
135+
#### Strategy "regex_search"
136+
137+
Searches for the first line that matches a provided regular expressions and returns the contents of that line that are captured in a regular expression capture group.
138+
139+
Configuration:
140+
* `regex_username`: The regular expression to apply. Has to contain a single capture group for indicating the data to extract. Default: `^username: +(.*)$`.
141+
142+
#### Strategy "entry_name"
143+
144+
Returns the last path fragment of the passwordstore entry as the username.
145+
For instance, if a regular [pass] call would be `pass show dev/github.com/languitar`, the returned username would be `languitar`.
146+
147+
No configuration options.
148+
103149
## Command Line Options
104150

105151
`-l` can be given as an option to the script to produce logging output on stderr.

passgithelper.py

Lines changed: 220 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
"""
88

99

10+
import abc
1011
import argparse
1112
import configparser
1213
import fnmatch
1314
import logging
1415
import os
1516
import os.path
17+
import re
1618
import subprocess
1719
import sys
18-
from typing import Dict, Optional, Sequence
20+
from typing import Dict, Optional, Sequence, Text
1921

2022
import xdg.BaseDirectory
2123

@@ -125,6 +127,200 @@ def parse_request() -> Dict[str, str]:
125127
return request
126128

127129

130+
class DataExtractor(abc.ABC):
131+
"""Interface for classes that extract values from pass entries."""
132+
133+
def __init__(self, option_suffix: Text = ''):
134+
"""
135+
Create a new instance.
136+
137+
Args:
138+
option_suffix:
139+
Suffix to put behind names of configuration keys for this
140+
instance. Subclasses must use this for their own options.
141+
"""
142+
self._option_suffix = option_suffix
143+
144+
@abc.abstractmethod
145+
def configure(self, config: configparser.SectionProxy):
146+
"""
147+
Configure the extractor from the mapping section.
148+
149+
Args:
150+
config:
151+
configuration section for the entry
152+
"""
153+
pass
154+
155+
@abc.abstractmethod
156+
def get_value(self,
157+
entry_name: Text,
158+
entry_lines: Sequence[Text]) -> Optional[Text]:
159+
"""
160+
Return the extracted value.
161+
162+
Args:
163+
entry_name:
164+
Name of the pass entry the value shall be extracted from
165+
entry_lines:
166+
The entry contents as a sequence of text lines
167+
168+
Returns:
169+
The extracted value or ``None`` if nothing applicable can be found
170+
in the entry.
171+
"""
172+
pass
173+
174+
175+
class SkippingDataExtractor(DataExtractor):
176+
"""
177+
Extracts data from a pass entry and optionally strips a prefix.
178+
179+
The prefix is a fixed amount of characters.
180+
"""
181+
182+
def __init__(self, prefix_length: int, option_suffix: Text = '') -> None:
183+
"""
184+
Create a new instance.
185+
186+
Args:
187+
prefix_length:
188+
Amount of characters to skip at the beginning of the entry
189+
"""
190+
super().__init__(option_suffix)
191+
self._prefix_length = prefix_length
192+
193+
@abc.abstractmethod
194+
def configure(self, config):
195+
"""Configure the amount of characters to skip."""
196+
self._prefix_length = config.getint(
197+
'skip{suffix}'.format(suffix=self._option_suffix),
198+
fallback=self._prefix_length)
199+
200+
@abc.abstractmethod
201+
def _get_raw(self,
202+
entry_name: Text,
203+
entry_lines: Sequence[Text]) -> Optional[Text]:
204+
pass
205+
206+
def get_value(self,
207+
entry_name: Text,
208+
entry_lines: Sequence[Text]) -> Optional[Text]:
209+
"""See base class method."""
210+
raw_value = self._get_raw(entry_name, entry_lines)
211+
if raw_value is not None:
212+
return raw_value[self._prefix_length:]
213+
else:
214+
return None
215+
216+
217+
class SpecificLineExtractor(SkippingDataExtractor):
218+
"""Extracts a specific line number from an entry."""
219+
220+
def __init__(self,
221+
line: int, prefix_length: int,
222+
option_suffix: Text = '') -> None:
223+
"""
224+
Create a new instance.
225+
226+
Args:
227+
line:
228+
the line to extract, counting from zero
229+
prefix_length:
230+
Amount of characters to skip at the beginning of the line
231+
option_suffix:
232+
Suffix for each configuration option
233+
"""
234+
super().__init__(prefix_length, option_suffix)
235+
self._line = line
236+
237+
def configure(self, config):
238+
"""See base class method."""
239+
super().configure(config)
240+
self._line = config.getint(
241+
'line{suffix}'.format(suffix=self._option_suffix),
242+
fallback=self._line)
243+
244+
def _get_raw(self,
245+
entry_name: Text,
246+
entry_lines: Sequence[Text]) -> Optional[Text]:
247+
if len(entry_lines) > self._line:
248+
return entry_lines[self._line]
249+
else:
250+
return None
251+
252+
253+
class RegexSearchExtractor(DataExtractor):
254+
"""Extracts data using a regular expression with capture group."""
255+
256+
def __init__(self, regex: str, option_suffix: str):
257+
"""
258+
Create a new instance.
259+
260+
Args:
261+
regex:
262+
The regular expression describing the entry line to match. The
263+
first matching line is selected. The expression must contain a
264+
single capture group that contains the data to return.
265+
option_suffix:
266+
Suffix for each configuration option
267+
"""
268+
super().__init__(option_suffix)
269+
self._regex = self._build_matcher(regex)
270+
271+
def _build_matcher(self, regex):
272+
matcher = re.compile(regex)
273+
if matcher.groups != 1:
274+
raise ValueError('Provided regex "{regex}" must contain a single '
275+
'capture group for the value to return.'.format(
276+
regex=regex))
277+
return matcher
278+
279+
def configure(self, config):
280+
"""See base class method."""
281+
super().configure(config)
282+
283+
self._regex = self._build_matcher(config.get(
284+
'regex{suffix}'.format(suffix=self._option_suffix),
285+
fallback=self._regex.pattern))
286+
287+
def get_value(self,
288+
entry_name: Text,
289+
entry_lines: Sequence[Text]) -> Optional[Text]:
290+
"""See base class method."""
291+
# Search through all lines and return the first matching one
292+
for line in entry_lines:
293+
match = self._regex.match(line)
294+
if match:
295+
return match.group(1)
296+
# nothing matched
297+
return None
298+
299+
300+
class EntryNameExtractor(DataExtractor):
301+
"""Return the last path fragment of the pass entry as the desired value."""
302+
303+
def configure(self, config):
304+
"""Configure nothing."""
305+
pass
306+
307+
def get_value(self,
308+
entry_name: Text,
309+
entry_lines: Sequence[Text]) -> Optional[Text]:
310+
"""See base class method."""
311+
return os.path.split(entry_name)[1]
312+
313+
314+
_line_extractor_name = 'specific_line'
315+
_username_extractors = {
316+
_line_extractor_name: SpecificLineExtractor(
317+
1, 0, option_suffix='_username'),
318+
'regex_search': RegexSearchExtractor(r'^username: +(.*)$',
319+
option_suffix='_username'),
320+
'entry_name': EntryNameExtractor(option_suffix='_username'),
321+
}
322+
323+
128324
def get_password(request, mapping) -> None:
129325
"""
130326
Resolve the given credential request in the provided mapping definition.
@@ -147,8 +343,8 @@ def get_password(request, mapping) -> None:
147343
if 'path' in request:
148344
host = '/'.join([host, request['path']])
149345

150-
def decode_skip(line, skip):
151-
return line.decode('utf-8')[skip:]
346+
def skip(line, skip):
347+
return line[skip:]
152348

153349
LOGGER.debug('Iterating mapping to match against host "%s"', host)
154350
for section in mapping.sections():
@@ -158,19 +354,29 @@ def decode_skip(line, skip):
158354
# TODO handle exceptions
159355
pass_target = mapping.get(section, 'target').replace(
160356
"${host}", request['host'])
161-
skip_password_chars = mapping.getint(
162-
section, 'skip_password', fallback=0)
163-
skip_username_chars = mapping.getint(
164-
section, 'skip_username', fallback=0)
357+
358+
password_extractor = SpecificLineExtractor(
359+
0, 0, option_suffix='_password')
360+
password_extractor.configure(mapping[section])
361+
# username_extractor = SpecificLineExtractor(
362+
# 1, 0, option_suffix='_username')
363+
username_extractor = _username_extractors[mapping[section].get(
364+
'username_extractor', fallback=_line_extractor_name)]
365+
username_extractor.configure(mapping[section])
366+
165367
LOGGER.debug('Requesting entry "%s" from pass', pass_target)
166-
output = subprocess.check_output(['pass', 'show', pass_target])
368+
output = subprocess.check_output(
369+
['pass', 'show', pass_target]).decode('utf-8')
167370
lines = output.splitlines()
168-
if len(lines) >= 1:
169-
print('password={}'.format( # noqa: P101
170-
decode_skip(lines[0], skip_password_chars)))
171-
if 'username' not in request and len(lines) >= 2:
172-
print('username={}'.format( # noqa: P101
173-
decode_skip(lines[1], skip_username_chars)))
371+
372+
password = password_extractor.get_value(pass_target, lines)
373+
username = username_extractor.get_value(pass_target, lines)
374+
if password:
375+
print('password={password}'.format( # noqa: T001
376+
password=password))
377+
if 'username' not in request and username:
378+
print('username={username}'.format( # noqa: T001
379+
username=username))
174380
return
175381

176382
LOGGER.warning('No mapping matched')
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[mytest.com]
2+
username_extractor=entry_name
3+
target=dev/mytest/myuser
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[mytest.com]
2+
username_extractor=regex_search
3+
regex_username=^myuser: (.*)$
4+
target=dev/mytest
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[DEFAULT]
2+
username_extractor=doesntexist
3+
4+
[mytest.com]
5+
target=dev/mytest

0 commit comments

Comments
 (0)