diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d70c4a..bc9f3e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ ambiguous situations.
### Added
- Download of page descriptions
- Forum download support
+- `pass` authenticator
### Changed
- Add `cpp` extension to default `link_regex` of IPD crawler
diff --git a/CONFIG.md b/CONFIG.md
index f572a80..0f114ed 100644
--- a/CONFIG.md
+++ b/CONFIG.md
@@ -223,6 +223,23 @@ is stored in the keyring.
- `keyring_name`: The service name PFERD uses for storing credentials. (Default:
`PFERD`)
+### The `pass` authenticator
+
+This authenticator queries the [`pass` password manager][3] for a username and
+password. It tries to be mostly compatible with [browserpass][4] and
+[passff][5], so see those links for an overview of the format. If PFERD fails
+to load your password, you can use the `--explain` flag to see why.
+
+- `passname`: The name of the password to use (Required)
+- `username_prefixes`: A comma-separated list of username line prefixes
+ (Default: `login,username,user`)
+- `password_prefixes`: A comma-separated list of password line prefixes
+ (Default: `password,pass,secret`)
+
+[3]: "Pass: The Standard Unix Password Manager"
+[4]: "Organizing password store"
+[5]: "Multi-line format"
+
### The `tfa` authenticator
This authenticator prompts the user on the console for a two-factor
@@ -316,7 +333,7 @@ is a regular expression and `TARGET` an f-string based template. If a path
matches `SOURCE`, the output path is created using `TARGET` as template.
`SOURCE` is automatically anchored.
-`TARGET` uses Python's [format string syntax][3]. The *n*-th capturing group can
+`TARGET` uses Python's [format string syntax][6]. The *n*-th capturing group can
be referred to as `{g}` (e.g. `{g3}`). `{g0}` refers to the original path.
If capturing group *n*'s contents are a valid integer, the integer value is
available as `{i}` (e.g. `{i3}`). If capturing group *n*'s contents are a
@@ -337,7 +354,7 @@ Example: `f(oo+)/be?ar -re-> B{g1.upper()}H/fear`
- Converts `fooooo/bear` into `BOOOOOH/fear`
- Converts `foo/bar/baz` into `BOOH/fear/baz`
-[3]: "Format String Syntax"
+[6]: "Format String Syntax"
### The `-name-re->` arrow
diff --git a/PFERD/auth/__init__.py b/PFERD/auth/__init__.py
index 277cade..aa3ba8e 100644
--- a/PFERD/auth/__init__.py
+++ b/PFERD/auth/__init__.py
@@ -5,6 +5,7 @@ from ..config import Config
from .authenticator import Authenticator, AuthError, AuthLoadError, AuthSection # noqa: F401
from .credential_file import CredentialFileAuthenticator, CredentialFileAuthSection
from .keyring import KeyringAuthenticator, KeyringAuthSection
+from .pass_ import PassAuthenticator, PassAuthSection
from .simple import SimpleAuthenticator, SimpleAuthSection
from .tfa import TfaAuthenticator
@@ -19,6 +20,8 @@ AUTHENTICATORS: Dict[str, AuthConstructor] = {
CredentialFileAuthenticator(n, CredentialFileAuthSection(s), c),
"keyring": lambda n, s, c:
KeyringAuthenticator(n, KeyringAuthSection(s)),
+ "pass": lambda n, s, c:
+ PassAuthenticator(n, PassAuthSection(s)),
"simple": lambda n, s, c:
SimpleAuthenticator(n, SimpleAuthSection(s)),
"tfa": lambda n, s, c:
diff --git a/PFERD/auth/pass_.py b/PFERD/auth/pass_.py
new file mode 100644
index 0000000..4c8e775
--- /dev/null
+++ b/PFERD/auth/pass_.py
@@ -0,0 +1,98 @@
+import re
+import subprocess
+from typing import List, Tuple
+
+from ..logging import log
+from .authenticator import Authenticator, AuthError, AuthSection
+
+
+class PassAuthSection(AuthSection):
+ def passname(self) -> str:
+ if (value := self.s.get("passname")) is None:
+ self.missing_value("passname")
+ return value
+
+ def username_prefixes(self) -> List[str]:
+ value = self.s.get("username_prefixes", "login,username,user")
+ return [prefix.lower() for prefix in value.split(",")]
+
+ def password_prefixes(self) -> List[str]:
+ value = self.s.get("password_prefixes", "password,pass,secret")
+ return [prefix.lower() for prefix in value.split(",")]
+
+
+class PassAuthenticator(Authenticator):
+ PREFIXED_LINE_RE = r"([a-zA-Z]+):\s?(.*)" # to be used with fullmatch
+
+ def __init__(self, name: str, section: PassAuthSection) -> None:
+ super().__init__(name)
+
+ self._passname = section.passname()
+ self._username_prefixes = section.username_prefixes()
+ self._password_prefixes = section.password_prefixes()
+
+ async def credentials(self) -> Tuple[str, str]:
+ log.explain_topic("Obtaining credentials from pass")
+
+ try:
+ log.explain(f"Calling 'pass show {self._passname}'")
+ result = subprocess.check_output(["pass", "show", self._passname], text=True)
+ except subprocess.CalledProcessError as e:
+ raise AuthError(f"Failed to get password info from {self._passname}: {e}")
+
+ prefixed = {}
+ unprefixed = []
+ for line in result.strip().splitlines():
+ if match := re.fullmatch(self.PREFIXED_LINE_RE, line):
+ prefix = match.group(1).lower()
+ value = match.group(2)
+ log.explain(f"Found prefixed line {line!r} with prefix {prefix!r}, value {value!r}")
+ if prefix in prefixed:
+ raise AuthError(f"Prefix {prefix} specified multiple times")
+ prefixed[prefix] = value
+ else:
+ log.explain(f"Found unprefixed line {line!r}")
+ unprefixed.append(line)
+
+ username = None
+ for prefix in self._username_prefixes:
+ log.explain(f"Looking for username at prefix {prefix!r}")
+ if prefix in prefixed:
+ username = prefixed[prefix]
+ log.explain(f"Found username {username!r}")
+ break
+
+ password = None
+ for prefix in self._password_prefixes:
+ log.explain(f"Looking for password at prefix {prefix!r}")
+ if prefix in prefixed:
+ password = prefixed[prefix]
+ log.explain(f"Found password {password!r}")
+ break
+
+ if password is None and username is None:
+ log.explain("No username and password found so far")
+ log.explain("Using first unprefixed line as password")
+ log.explain("Using second unprefixed line as username")
+ elif password is None:
+ log.explain("No password found so far")
+ log.explain("Using first unprefixed line as password")
+ elif username is None:
+ log.explain("No username found so far")
+ log.explain("Using first unprefixed line as username")
+
+ if password is None:
+ if not unprefixed:
+ log.explain("Not enough unprefixed lines left")
+ raise AuthError("Password could not be determined")
+ password = unprefixed.pop(0)
+ log.explain(f"Found password {password!r}")
+
+ if username is None:
+ if not unprefixed:
+ log.explain("Not enough unprefixed lines left")
+ raise AuthError("Username could not be determined")
+ username = unprefixed.pop(0)
+ log.explain(f"Found username {username!r}")
+
+ return username, password