Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-osc-tiny for openSUSE:Factory checked in at 2022-06-10 15:57:37 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-osc-tiny (Old) and /work/SRC/openSUSE:Factory/.python-osc-tiny.new.1548 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-osc-tiny" Fri Jun 10 15:57:37 2022 rev:13 rq:981539 version:0.6.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-osc-tiny/python-osc-tiny.changes 2022-05-18 13:13:29.506678560 +0200 +++ /work/SRC/openSUSE:Factory/.python-osc-tiny.new.1548/python-osc-tiny.changes 2022-06-10 15:57:58.868853082 +0200 @@ -1,0 +2,7 @@ +Thu Jun 9 11:53:07 UTC 2022 - Andreas Hasenkopf <ahasenk...@suse.com> + +- Release 0.6.0 + * Support for the "Signature authentication scheme" + * Revised method to retrieve credentials from `osc` + +------------------------------------------------------------------- Old: ---- osc-tiny-0.5.0.tar.gz New: ---- osc-tiny-0.6.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-osc-tiny.spec ++++++ --- /var/tmp/diff_new_pack.lGkaGZ/_old 2022-06-10 15:58:00.392854930 +0200 +++ /var/tmp/diff_new_pack.lGkaGZ/_new 2022-06-10 15:58:00.396854935 +0200 @@ -19,7 +19,7 @@ %{?!python_module:%define python_module() python-%{**} python3-%{**}} %define skip_python2 1 Name: python-osc-tiny -Version: 0.5.0 +Version: 0.6.0 Release: 0 Summary: Client API for openSUSE BuildService License: MIT @@ -44,6 +44,7 @@ Requires: python-python-dateutil Requires: python-pytz Requires: python-requests +Suggests: openssh BuildArch: noarch %python_subpackages ++++++ osc-tiny-0.5.0.tar.gz -> osc-tiny-0.6.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/PKG-INFO new/osc-tiny-0.6.0/PKG-INFO --- old/osc-tiny-0.5.0/PKG-INFO 2022-05-17 14:54:34.581646200 +0200 +++ new/osc-tiny-0.6.0/PKG-INFO 2022-06-09 13:50:02.441182900 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: osc-tiny -Version: 0.5.0 +Version: 0.6.0 Summary: Client API for openSUSE BuildService Home-page: http://github.com/crazyscientist/osc-tiny Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master @@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 Description-Content-Type: text/markdown License-File: LICENSE diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osc_tiny.egg-info/PKG-INFO new/osc-tiny-0.6.0/osc_tiny.egg-info/PKG-INFO --- old/osc-tiny-0.5.0/osc_tiny.egg-info/PKG-INFO 2022-05-17 14:54:33.000000000 +0200 +++ new/osc-tiny-0.6.0/osc_tiny.egg-info/PKG-INFO 2022-06-09 13:50:01.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: osc-tiny -Version: 0.5.0 +Version: 0.6.0 Summary: Client API for openSUSE BuildService Home-page: http://github.com/crazyscientist/osc-tiny Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master @@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 Description-Content-Type: text/markdown License-File: LICENSE diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osc_tiny.egg-info/SOURCES.txt new/osc-tiny-0.6.0/osc_tiny.egg-info/SOURCES.txt --- old/osc-tiny-0.5.0/osc_tiny.egg-info/SOURCES.txt 2022-05-17 14:54:34.000000000 +0200 +++ new/osc-tiny-0.6.0/osc_tiny.egg-info/SOURCES.txt 2022-06-09 13:50:02.000000000 +0200 @@ -40,6 +40,7 @@ osctiny/tests/osc/__init__.py osctiny/tests/osc/conf.py osctiny/utils/__init__.py +osctiny/utils/auth.py osctiny/utils/backports.py osctiny/utils/base.py osctiny/utils/changelog.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/osc.py new/osc-tiny-0.6.0/osctiny/osc.py --- old/osc-tiny-0.5.0/osctiny/osc.py 2022-05-17 14:54:21.000000000 +0200 +++ new/osc-tiny-0.6.0/osctiny/osc.py 2022-06-09 13:49:46.000000000 +0200 @@ -3,9 +3,12 @@ --------------- """ from __future__ import unicode_literals + +import typing from io import BufferedReader, BytesIO, StringIO import gc import logging +from pathlib import Path import re from ssl import get_default_verify_paths import time @@ -16,6 +19,7 @@ from lxml.objectify import fromstring, makeparser from requests import Session, Request from requests.auth import HTTPBasicAuth +from requests.cookies import RequestsCookieJar, cookiejar_from_dict from requests.exceptions import ConnectionError as _ConnectionError from .extensions.buildresults import Build @@ -28,6 +32,7 @@ from .extensions.bs_requests import Request as BsRequest from .extensions.search import Search from .extensions.users import Group, Person +from .utils.auth import HttpSignatureAuth from .utils.conf import get_credentials from .utils.errors import OscError @@ -78,10 +83,12 @@ - :py:attr:`origins` :param url: API URL of a BuildService instance - :param username: Credential for login - :param password: Password for login + :param username: Username + :param password: Password; this is either the user password or the SSH passphrase, if + ``ssh_key_file`` is defined :param verify: See `SSL Cert Verification`_ for more details :param cache: Store API responses in a cache + :param ssh_key_file: Path to SSH private key file :raises osctiny.errors.OscError: if no credentials are provided .. versionadded:: 0.1.1 @@ -102,6 +109,10 @@ .. versionchanged:: 0.4.0 Raises an exception when no credentials are provided + .. versionchanged:: 0.6.0 + Support for 2FA authentication (i.e. added the ``ssh_key_file`` parameter and changed the + meaning of the ``password`` parameter + .. _SSL Cert Verification: http://docs.python-requests.org/en/master/user/advanced/ #ssl-cert-verification @@ -115,22 +126,26 @@ default_connection_retries = 5 default_retry_timeout = 5 - def __init__(self, url=None, username=None, password=None, verify=None, - cache=False): + def __init__(self, url: str = None, username: typing.Optional[str] = None, + password: typing.Optional[str] = None, verify: typing.Optional[str] = None, + cache: bool = False, + ssh_key_file: typing.Optional[typing.Union[Path, str]] = None): # Basic URL and authentication settings self.url = url or self.url self.username = username or self.username self.password = password or self.password + self.verify = verify + self.cache = cache + self.ssh_key = ssh_key_file + if self.ssh_key is not None and not isinstance(self.ssh_key, Path): + self.ssh_key = Path(self.ssh_key) - if not self.username and not self.password: + if not self.username and not self.password and not self.ssh_key: try: - self.username, self.password = get_credentials(self.url) + self.username, self.password, self.ssh_key = get_credentials(self.url) except (ValueError, NotImplementedError, FileNotFoundError) as error: raise OscError from error - self._session = Session() - self._session.verify = verify or get_default_verify_paths().capath - self.auth = HTTPBasicAuth(self.username, self.password) self.parser = makeparser(huge_tree=True) # API endpoints @@ -146,8 +161,50 @@ self.search = Search(osc_obj=self) self.users = Person(osc_obj=self) + self._session, self.session = None, None + + def __del__(self): + # Just in case ;-) + gc.collect() + + @property + def cookies(self) -> RequestsCookieJar: + """ + Access session cookies + """ + if self._session is None: + self._init_session() + + return self.session.cookies + + @cookies.setter + def cookies(self, value: RequestsCookieJar): + if not isinstance(value, (RequestsCookieJar, dict)): + raise TypeError(f"Expected a cookie jar or dict. Got instead: {type(value)}") + + if self._session is None: + self._init_session() + + if isinstance(value, RequestsCookieJar): + self._session.cookies = value + else: + self._session.cookies = cookiejar_from_dict(value) + + def _init_session(self): + """ + Lazy session initialization + """ + self._session = Session() + self._session.verify = self.verify or get_default_verify_paths().capath + + if self.ssh_key is not None: + self._session.auth = HttpSignatureAuth(username=self.username, password=self.password, + ssh_key_file=self.ssh_key) + else: + self._session.auth = HTTPBasicAuth(self.username, self.password) + # Cache - if cache: + if self.cache: # pylint: disable=broad-except try: self.session = CacheControl(self._session) @@ -158,10 +215,6 @@ else: self.session = self._session - def __del__(self): - # Just in case ;-) - gc.collect() - def request(self, url, method="GET", stream=False, data=None, params=None, raise_for_status=True, timeout=None): """ @@ -191,7 +244,7 @@ .. versionadded:: 0.1.7 Added parameter `params` - .. versionchanged:: {{ NEXT_RELEASE }} + .. versionchanged:: 0.5.0 Added logging of request/response :param url: Full URL @@ -215,6 +268,9 @@ https://2.python-requests.org/en/master/user/advanced/#timeouts """ timeout = timeout or self.default_timeout + if self._session is None: + self._init_session() + if stream: session = self._session else: @@ -223,7 +279,6 @@ req = Request( method, url.replace("#", quote("#")).replace("?", quote("?")), - auth=self.auth, data=self.handle_params(data), params=self.handle_params(params) ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/tests/test_basic.py new/osc-tiny-0.6.0/osctiny/tests/test_basic.py --- old/osc-tiny-0.5.0/osctiny/tests/test_basic.py 2022-05-17 14:54:21.000000000 +0200 +++ new/osc-tiny-0.6.0/osctiny/tests/test_basic.py 2022-06-09 13:49:46.000000000 +0200 @@ -75,7 +75,6 @@ def callback(headers, params, request): match = pattern.match(request.url) self.assertIsNotNone(match) - print(match.groups()) self.assertEqual(unquote_plus(match.group("filename")), filename) for special_c in special_chars: self.assertNotIn(special_c, match.group("filename")) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/tests/test_utils.py new/osc-tiny-0.6.0/osctiny/tests/test_utils.py --- old/osc-tiny-0.5.0/osctiny/tests/test_utils.py 2022-05-17 14:54:21.000000000 +0200 +++ new/osc-tiny-0.6.0/osctiny/tests/test_utils.py 2022-06-09 13:49:46.000000000 +0200 @@ -15,6 +15,7 @@ from ..utils.changelog import ChangeLog, Entry from ..utils.conf import get_config_path, get_credentials +from ..utils.mapping import Mappable sys.path.append(os.path.dirname(__file__)) @@ -77,6 +78,19 @@ """ +class TestMappable(TestCase): + def test(self): + m = Mappable(a="a", b="b") + m["c"] = "c" + + for key in ('a', 'b', 'c'): + with self.subTest(f"get {key}"): + self.assertEqual(key, m.get(key)) + + with self.subTest("Default"): + self.assertEqual("f????", m.get("d", "f????")) + + class TestEntry(TestCase): def test_timestamp(self): cet = timezone("Europe/Berlin") @@ -317,7 +331,7 @@ self.assertIn("Cannot parse changelog entry", wmock.call_args[0][0]) -@mock.patch("osc.conf", side_effect=ImportError, create=True) +@mock.patch("osctiny.utils.conf._conf", new_callable=lambda: None, create=True) @mock.patch("pathlib.Path.is_file", return_value=True) class TestConfig(TestCase): def test_get_config_path(self, *_): @@ -334,8 +348,8 @@ _, path1 = mkstemp() _, path2 = mkstemp() - expected_insecure_credentials = ("my-dummy-user", "my-insecure-dummy-password") - expected_secure_credentials = ('my-dummy-user', 'my-secure-dummy-password') + expected_insecure_credentials = ("my-dummy-user", "my-insecure-dummy-password", None) + expected_secure_credentials = ('my-dummy-user', 'my-secure-dummy-password', None) with open(path1, "w") as handle: handle.write("[http://api.dummy-bs.org]\n") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/utils/auth.py new/osc-tiny-0.6.0/osctiny/utils/auth.py --- old/osc-tiny-0.5.0/osctiny/utils/auth.py 1970-01-01 01:00:00.000000000 +0100 +++ new/osc-tiny-0.6.0/osctiny/utils/auth.py 2022-06-09 13:49:46.000000000 +0200 @@ -0,0 +1,135 @@ +""" +Authentication handlers for 2FA +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: 0.6.0 +""" +import typing +from base64 import b64decode, b64encode +from pathlib import Path +from subprocess import Popen, PIPE +import re +import sys +from time import time + +from requests.auth import HTTPDigestAuth +from requests.cookies import extract_cookies_to_jar +from requests.utils import parse_dict_header +from requests import Response + +from .errors import OscError + + +class HttpSignatureAuth(HTTPDigestAuth): + """ + Implementation of the "Signature authentication scheme" + + .. note:: + + This seems to be a variation of the `HTTP Message Signatures`_ specification. + + See also the `reference implementation for osc`_ + + .. _HTTP Message Signatures: + https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/ + + .. _reference implementation for osc: https://github.com/openSUSE/osc/pull/1032 + + :param username: The username + :param password: Passphrase for SSH key. This can be omitted, if ``ssh-agent`` is also installed + :param ssh_key_file: Path of SSK key + """ + def __init__(self, username: str, password: typing.Optional[str], ssh_key_file: Path): + super().__init__(username=username, password=password) + if not ssh_key_file.is_file(): + raise FileNotFoundError(f"SSH key at location does not exist: {ssh_key_file}") + self.ssh_key_file = ssh_key_file + self.pattern = re.compile(r"(?<=\)) (?=\()") + + def __eq__(self, other: 'HttpSignatureAuth') -> bool: + return self.ssh_key_file == getattr(other, 'ssh_key_file', None) and super().__eq__(other) + + def split_headers(self, headers: str) -> typing.List[str]: + """ + Split ``headers`` parameter from ``WWW-Authenticate Signature`` header + + :param headers: Value of the ``headers`` parameter + """ + parts = self.pattern.split(headers) + return [part.strip("()") for part in parts] + + def ssh_sign(self) -> str: + """ + Solve the challenge via SSH signing + """ + data = "\n".join(f"({header}): {self._thread_local.chal[header]}" + for header in self._thread_local.chal["headers"]) + cmd = ['ssh-keygen', '-Y', 'sign', '-f', self.ssh_key_file.as_posix(), '-q', + '-n', self._thread_local.chal.get('realm', '')] + if self.password: + cmd += ['-P', self.password] + + encoding = sys.getdefaultencoding() + with Popen(cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE) as proc: + signature, error = proc.communicate(data.encode(encoding)) + if proc.returncode: + raise OscError(f"ssh-keygen returned {proc.returncode}: {error}") + + match = re.match(br"\A-----BEGIN SSH SIGNATURE-----\n(.*)\n-----END SSH SIGNATURE-----", + signature, re.S) + if not match: + raise OscError("Could not generate challenge response") + return b64encode(b64decode(match.group(1))).decode(encoding) + + def build_digest_header(self, method: str, url: str) -> str: + """ + Generate Authentication header + """ + headers = " ".join(f"({header})" for header in self._thread_local.chal["headers"]) + return f'Signature keyId="{self.username}",algorithm="ssh",signature={self.ssh_sign()},' \ + f'headers="{headers}",created={self._thread_local.chal["created"]}' + + def handle_401(self, r: Response, **kwargs) -> Response: + """ + Handle authentication in case of 401 + + Contents of method copied from :py:meth:`requests.auth.HTTPDigestAuth.handle_401` and edited + """ + if not 400 <= r.status_code < 500: + self._thread_local.num_401_calls = 1 + return r + + if self._thread_local.pos is not None: + # Rewind the file position indicator of the body to where + # it was to resend the request. + r.request.body.seek(self._thread_local.pos) + s_auth = r.headers.get('www-authenticate', '') + + if s_auth.lower().startswith("signature") and self._thread_local.num_401_calls < 2: + self._thread_local.num_401_calls += 1 + + _, challenge = s_auth.split(" ", maxsplit=1) + challenge = parse_dict_header(challenge) + challenge.setdefault("headers", ["created"]) + challenge["created"] = int(time()) + challenge["headers"] = self.split_headers(challenge["headers"]) + self._thread_local.chal.update(challenge) + + # The following is unchanged from :py:meth:`requests.auth.HTTPDigestAuth.handle_401`, + # so we ignore linter issues about it. + # pylint: disable=pointless-statement,protected-access + r.content + r.close() + prep = r.request.copy() + extract_cookies_to_jar(prep._cookies, r.request, r.raw) + prep.prepare_cookies(prep._cookies) + prep.headers['Authorization'] = self.build_digest_header( + prep.method, prep.url) + _r = r.connection.send(prep, **kwargs) + _r.history.append(r) + _r.request = prep + + return _r + + self._thread_local.num_401_calls = 1 + return r diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/utils/conf.py new/osc-tiny-0.6.0/osctiny/utils/conf.py --- old/osc-tiny-0.5.0/osctiny/utils/conf.py 2022-05-17 14:54:21.000000000 +0200 +++ new/osc-tiny-0.6.0/osctiny/utils/conf.py 2022-06-09 13:49:46.000000000 +0200 @@ -8,6 +8,7 @@ .. versionadded:: 0.4.0 """ +import typing from base64 import b64decode from bz2 import decompress from configparser import ConfigParser, NoSectionError @@ -17,11 +18,12 @@ try: from osc import conf as _conf + from osc.oscerr import ConfigError, ConfigMissingApiurl except ImportError: _conf = None -def get_config_path(): +def get_config_path() -> Path: """ Return path of ``osc`` configuration file @@ -43,7 +45,9 @@ raise FileNotFoundError("No `osc` configuration file found") -def get_credentials(url=None): +# pylint: disable=too-many-branches +def get_credentials(url: typing.Optional[str] = None) \ + -> typing.Tuple[str, str, typing.Optional[Path]]: """ Get credentials for Build Service instance identified by ``url`` @@ -56,26 +60,31 @@ :param str url: URL of Build Service instance (including schema). If not specified, the value from the ``apiurl`` parameter in the config file will be used. - :return: (username, password) + :return: (username, password, SSH private key path) :raises ValueError: if config provides no credentials """ if _conf is not None: - # pylint: disable=protected-access - parser = _conf.get_configParser() try: + _conf.get_config() if url is None: - url = parser["general"].get("apiurl", url) - cred_mgr = _conf._get_credentials_manager(url, parser) - username = _conf._extract_user_compat(parser, url, cred_mgr) - except (KeyError, NoSectionError) as error: - raise ValueError("`osc` config does not provide the default API URL") from error + # get the default api url from osc's config + url = _conf.config["apiurl"] + # and now fetch the options for that particular url + api_config = _conf.get_apiurl_api_host_options(url) + username = api_config["user"] + password = api_config["pass"] + sshkey = Path(api_config["sshkey"]) if api_config["sshkey"] else None + except (ConfigError, ConfigMissingApiurl) as error: + if isinstance(error, ConfigError): + raise ValueError("`osc` config was not found.") from error + # this is the case of ConfigMissingApiurl + raise ValueError("`osc` config has no options for URL {}".format(url)) from error if not username: raise ValueError("`osc` config provides no username for URL {}".format(url)) - password = cred_mgr.get_password(url, username, defer=False) if not password: raise ValueError("`osc` config provides no password for URL {}".format(url)) - return username, password + return username, password, sshkey warnings.warn("`osc` is not installed. Not all configuration backends of `osc` will be " "available.") @@ -104,4 +113,8 @@ if not password: raise ValueError("`osc` config provides no password for URL {}".format(url)) - return username, password + sshkey = parser[url].get("sshkey", None) + if sshkey: + sshkey = Path(sshkey) + + return username, password, sshkey diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/utils/mapping.py new/osc-tiny-0.6.0/osctiny/utils/mapping.py --- old/osc-tiny-0.5.0/osctiny/utils/mapping.py 2022-05-17 14:54:21.000000000 +0200 +++ new/osc-tiny-0.6.0/osctiny/utils/mapping.py 2022-06-09 13:49:46.000000000 +0200 @@ -48,7 +48,7 @@ def get(self, key, default=None): try: - return self.__getitem__(key) + return self[key] except KeyError: return default diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-tiny-0.5.0/setup.py new/osc-tiny-0.6.0/setup.py --- old/osc-tiny-0.5.0/setup.py 2022-05-17 14:54:21.000000000 +0200 +++ new/osc-tiny-0.6.0/setup.py 2022-06-09 13:49:46.000000000 +0200 @@ -19,7 +19,7 @@ setup( name='osc-tiny', - version='0.5.0', + version='0.6.0', description='Client API for openSUSE BuildService', long_description=long_description, long_description_content_type="text/markdown", @@ -40,5 +40,6 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ] )