From 38e3c4952f9436aad182b2253b94380f6cdcd1d2 Mon Sep 17 00:00:00 2001 From: mls Date: Tue, 26 Apr 2022 12:36:16 +0200 Subject: [PATCH 1/7] Simplify bad auth retry workaround needed for old python versions This changes the code back to retrying up to 5 times for old python version 2.6.6-2.7.9. The complete backport of the basic auth changes clutters up the code way to much for such a little gain. (This basically reverts commit 326abe0c8bf3fd8ba1a0684a84048d8764319352) --- osc/conf.py | 42 ++++++++---------------------------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/osc/conf.py b/osc/conf.py index 8cd6ceb3..80db0ace 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -524,6 +524,13 @@ def _build_opener(apiurl): return super(self.__class__, self).retry_http_basic_auth(host, req, realm) + def http_error_401(self, req, fp, code, msg, headers): + response = super(self.__class__, self).http_error_401(req, fp, code, msg, headers) + # workaround for http://bugs.python.org/issue9639 + if hasattr(self, 'retried'): + self.retried = 0 + return response + if 'last_opener' not in _build_opener.__dict__: _build_opener.last_opener = (None, None) @@ -538,43 +545,10 @@ def _build_opener(apiurl): # read proxies from env proxyhandler = ProxyHandler() - authhandler_class = OscHTTPBasicAuthHandler - # workaround for http://bugs.python.org/issue9639 - if sys.version_info >= (2, 6, 6) and sys.version_info < (2, 7, 9): - class OscHTTPBasicAuthHandlerCompat(OscHTTPBasicAuthHandler): - # The following two functions were backported from upstream 2.7. - def http_error_auth_reqed(self, authreq, host, req, headers): - authreq = headers.get(authreq, None) - - if authreq: - mo = AbstractBasicAuthHandler.rx.search(authreq) - if mo: - scheme, quote, realm = mo.groups() - if quote not in ['"', "'"]: - warnings.warn("Basic Auth Realm was unquoted", - UserWarning, 2) - if scheme.lower() == 'basic': - return self.retry_http_basic_auth(host, req, realm) - - def retry_http_basic_auth(self, host, req, realm): - self._rewind_request(req) - user, pw = self.passwd.find_user_password(realm, host) - if pw is not None: - raw = "%s:%s" % (user, pw) - auth = 'Basic %s' % base64.b64encode(raw).strip() - if req.get_header(self.auth_header, None) == auth: - return None - req.add_unredirected_header(self.auth_header, auth) - return self.parent.open(req, timeout=req.timeout) - else: - return None - - authhandler_class = OscHTTPBasicAuthHandlerCompat - options = config['api_host_options'][apiurl] # with None as first argument, it will always use this username/password # combination for urls for which arg2 (apisrv) is a super-url - authhandler = authhandler_class( \ + authhandler = OscHTTPBasicAuthHandler( \ HTTPPasswordMgrWithDefaultRealm()) authhandler.add_password(None, apiurl, options['user'], options['pass']) From 119ffd602737480cf0be1369dd93b9fe41c25faf Mon Sep 17 00:00:00 2001 From: mls Date: Tue, 26 Apr 2022 12:50:05 +0200 Subject: [PATCH 2/7] Rename OscHTTPBasicAuthHandler to OscHTTPAuthHandler We'll support more than one auth scheme in the future. --- osc/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osc/conf.py b/osc/conf.py index 80db0ace..651affbf 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -507,7 +507,7 @@ def _build_opener(apiurl): from osc.core import __version__ global config - class OscHTTPBasicAuthHandler(HTTPBasicAuthHandler, object): + class OscHTTPAuthHandler(HTTPBasicAuthHandler, object): # python2: inherit from object in order to make it a new-style class # (HTTPBasicAuthHandler is not a new-style class) def _rewind_request(self, req): @@ -548,7 +548,7 @@ def _build_opener(apiurl): options = config['api_host_options'][apiurl] # with None as first argument, it will always use this username/password # combination for urls for which arg2 (apisrv) is a super-url - authhandler = OscHTTPBasicAuthHandler( \ + authhandler = OscHTTPAuthHandler( \ HTTPPasswordMgrWithDefaultRealm()) authhandler.add_password(None, apiurl, options['user'], options['pass']) From e47a265388bac821db267f56b036523d8a6bcf7c Mon Sep 17 00:00:00 2001 From: mls Date: Tue, 26 Apr 2022 13:55:54 +0200 Subject: [PATCH 3/7] Allow to configure a ssh key in the config We support a global key and a key specific to an apiurl. --- osc/conf.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osc/conf.py b/osc/conf.py index 651affbf..27cd595e 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -112,6 +112,7 @@ DEFAULTS = {'apiurl': 'https://api.opensuse.org', 'user': None, 'pass': None, 'passx': None, + 'sshkey': None, 'packagecachedir': '/var/tmp/osbuild-packagecache', 'su-wrapper': 'sudo', @@ -222,7 +223,7 @@ boolean_opts = ['debug', 'do_package_tracking', 'http_debug', 'post_mortem', 'tr integer_opts = ['build-jobs'] api_host_options = ['user', 'pass', 'passx', 'aliases', 'http_headers', 'realname', 'email', 'sslcertck', 'cafile', 'capath', 'trusted_prj', - 'downloadurl'] + 'downloadurl', 'sshkey'] new_conf_template = """ [general] @@ -980,7 +981,7 @@ def get_config(override_conffile=None, 'http_headers': http_headers} api_host_options[apiurl] = APIHostOptionsEntry(entry) - optional = ('realname', 'email', 'sslcertck', 'cafile', 'capath') + optional = ('realname', 'email', 'sslcertck', 'cafile', 'capath', 'sshkey') for key in optional: if cp.has_option(url, key): if key == 'sslcertck': @@ -1011,6 +1012,9 @@ def get_config(override_conffile=None, else: api_host_options[apiurl]['downloadurl'] = None + if api_host_options[apiurl]['sshkey'] is None: + api_host_options[apiurl]['sshkey'] = config['sshkey'] + # add the auth data we collected to the config dict config['api_host_options'] = api_host_options config['apiurl_aliases'] = aliases From 99ba3719c798b1985d225ed6ca68f9732b8e8f13 Mon Sep 17 00:00:00 2001 From: mls Date: Tue, 26 Apr 2022 13:59:06 +0200 Subject: [PATCH 4/7] Add support for the Signature authentication scheme See https://tools.ietf.org/id/draft-cavage-http-signatures-12.html --- osc/conf.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/osc/conf.py b/osc/conf.py index 27cd595e..15cf281b 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -45,6 +45,8 @@ import sys import ssl import warnings import getpass +import time +import subprocess try: from http.cookiejar import LWPCookieJar, CookieJar @@ -54,6 +56,7 @@ try: from urllib.error import URLError from urllib.request import HTTPBasicAuthHandler, HTTPCookieProcessor, HTTPPasswordMgrWithDefaultRealm, ProxyHandler from urllib.request import AbstractHTTPHandler, build_opener, proxy_bypass, HTTPSHandler + from urllib.request import BaseHandler, parse_keqv_list, parse_http_list except ImportError: #python 2.x from cookielib import LWPCookieJar, CookieJar @@ -62,10 +65,11 @@ except ImportError: from urlparse import urlsplit from urllib2 import URLError, HTTPBasicAuthHandler, HTTPCookieProcessor, HTTPPasswordMgrWithDefaultRealm, ProxyHandler, AbstractBasicAuthHandler from urllib2 import AbstractHTTPHandler, build_opener, proxy_bypass, HTTPSHandler + from urllib2 import BaseHandler, parse_keqv_list, parse_http_list from . import OscConfigParser from osc import oscerr -from osc.util.helper import raw_input +from osc.util.helper import raw_input, decode_it from .oscsslexcp import NoSecureSSLError from osc import credentials @@ -532,6 +536,80 @@ def _build_opener(apiurl): self.retried = 0 return response + class OscHTTPSignatureAuthHandler(BaseHandler): + def __init__(self, user, sshkey): + super(self.__class__, self).__init__() + self.user = user + self.sshkey = sshkey + + def guess_keyfile(self): + sshdir = os.path.expanduser('~/.ssh') + keyfiles = ('id_ed25519', 'id_rsa') + for keyfile in keyfiles: + keyfile_path = os.path.join(sshdir, keyfile) + if os.path.isfile(keyfile_path): + return keyfile_path + raise oscerr.OscIOError(None, 'could not guess ssh identity keyfile') + + def ssh_sign(self, data, namespace, keyfile=None): + try: + data = bytes(data, 'utf-8') + except: + pass + if not keyfile: + keyfile = self.guess_keyfile() + else: + if '/' not in keyfile: + keyfile = '~/.ssh/' + keyfile + keyfile = os.path.expanduser(keyfile) + + cmd = ['ssh-keygen', '-Y', 'sign', '-f', keyfile, '-n', namespace, '-q'] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE) + stdout, _ = proc.communicate(data) + if proc.returncode: + raise oscerr.OscIOError(None, 'ssh-keygen signature creation failed: %d' % proc.returncode) + + signature = decode_it(stdout) + match = re.match(r"\A-----BEGIN SSH SIGNATURE-----\n(.*)\n-----END SSH SIGNATURE-----", signature, re.S) + if not match: + raise oscerr.OscIOError(None, 'could not extract ssh signature') + return base64.b64decode(match.group(1)) + + def get_authorization(self, req, chal): + realm = chal.get('realm', '') + now = int(time.time()) + sigdata = "(created): %d" % now + signature = self.ssh_sign(sigdata, realm, self.sshkey) + signature = decode_it(base64.b64encode(signature)) + return 'keyId="%s",algorithm="ssh",headers="(created)",created=%d,signature="%s"' \ + % (self.user, now, signature) + + def retry_http_signature_auth(self, req, auth): + old_auth_val = req.get_header('Authorization', None) + if old_auth_val: + old_scheme = old_auth_val.split()[0] + if old_scheme.lower() == 'signature': + return None + token, challenge = auth.split(' ', 1) + chal = parse_keqv_list(filter(None, parse_http_list(challenge))) + auth = self.get_authorization(req, chal) + if auth: + auth_val = 'Signature %s' % auth + req.add_unredirected_header('Authorization', auth_val) + return self.parent.open(req, timeout=req.timeout) + + def http_error_401(self, req, fp, code, msg, headers): + authreq = headers.get('www-authenticate', None) + if authreq: + scheme = authreq.split()[0] + if scheme.lower() == 'signature': + return self.retry_http_signature_auth(req, authreq) + raise ValueError("OscHTTPSignatureAuthHandler does not support" + " the following scheme: '%s'" % scheme) + + def sshkey_known(self): + return self.sshkey is not None + if 'last_opener' not in _build_opener.__dict__: _build_opener.last_opener = (None, None) From 0b826613d9fcd06a6217d3aa35761871d87f436e Mon Sep 17 00:00:00 2001 From: mls Date: Wed, 27 Apr 2022 12:00:31 +0200 Subject: [PATCH 5/7] Integrate signature authentication in the OscHTTPAuthHandler --- osc/conf.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/osc/conf.py b/osc/conf.py index 15cf281b..709ea036 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -515,6 +515,16 @@ def _build_opener(apiurl): class OscHTTPAuthHandler(HTTPBasicAuthHandler, object): # python2: inherit from object in order to make it a new-style class # (HTTPBasicAuthHandler is not a new-style class) + + def __init__(self, password_mgr=None, signatureauthhandler=None): + super(self.__class__, self).__init__(password_mgr) + self.signatureauthhandler = signatureauthhandler + + def add_parent(self, parent): + super(self.__class__, self).add_parent(parent) + if self.signatureauthhandler: + self.signatureauthhandler.add_parent(parent) + def _rewind_request(self, req): if hasattr(req.data, 'seek'): # if the request is issued again (this time with an @@ -524,12 +534,20 @@ def _build_opener(apiurl): # the Content-Length header (if present)) req.data.seek(0) - def retry_http_basic_auth(self, host, req, realm): - self._rewind_request(req) - return super(self.__class__, self).retry_http_basic_auth(host, req, - realm) - def http_error_401(self, req, fp, code, msg, headers): + self._rewind_request(req) + authreqs = {} + for authreq in headers.get_all('www-authenticate', []): + scheme = authreq.split()[0].lower() + authreqs[scheme] = authreq + if 'signature' in authreqs and self.signatureauthhandler and \ + (self.signatureauthhandler.sshkey_known() or 'basic' not in authreqs): + del headers['www-authenticate'] + headers['www-authenticate'] = authreqs['signature'] + return self.signatureauthhandler.http_error_401(req, fp, code, msg, headers) + if 'basic' in authreqs: + del headers['www-authenticate'] + headers['www-authenticate'] = authreqs['basic'] response = super(self.__class__, self).http_error_401(req, fp, code, msg, headers) # workaround for http://bugs.python.org/issue9639 if hasattr(self, 'retried'): @@ -625,10 +643,10 @@ def _build_opener(apiurl): proxyhandler = ProxyHandler() options = config['api_host_options'][apiurl] + signatureauthhandler = OscHTTPSignatureAuthHandler(options['user'], options['sshkey']) # with None as first argument, it will always use this username/password # combination for urls for which arg2 (apisrv) is a super-url - authhandler = OscHTTPAuthHandler( \ - HTTPPasswordMgrWithDefaultRealm()) + authhandler = OscHTTPAuthHandler(HTTPPasswordMgrWithDefaultRealm(), signatureauthhandler) authhandler.add_password(None, apiurl, options['user'], options['pass']) if options['sslcertck']: From b8f76f7990eb0d158ee50843e37fe10670fc6672 Mon Sep 17 00:00:00 2001 From: Michael Schroeder Date: Fri, 6 May 2022 16:32:14 +0200 Subject: [PATCH 6/7] OscHTTPSignatureAuthHandler: try to guess ssh key from the keys added to ssh-agent Based on a patch by Daniel Mach --- osc/conf.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/osc/conf.py b/osc/conf.py index 709ea036..c409e12c 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -560,7 +560,49 @@ def _build_opener(apiurl): self.user = user self.sshkey = sshkey + def list_ssh_agent_keys(self): + cmd = ['ssh-add', '-l'] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, _ = proc.communicate() + if proc.returncode == 0 and stdout.strip(): + return stdout.splitlines() + else: + return [] + + def is_ssh_private_keyfile(self, keyfile_path): + if not os.path.isfile(keyfile_path): + return False + with open(keyfile_path, "r") as f: + line = f.readline(100).strip() + if line == "-----BEGIN OPENSSH PRIVATE KEY-----": + return True + return False + + def list_ssh_dir_keys(self): + sshdir = os.path.expanduser('~/.ssh') + keys_in_home_ssh = {} + for keyfile in os.listdir(sshdir): + if keyfile.endswith(".pub"): + continue + keyfile_path = os.path.join(sshdir, keyfile) + if not self.is_ssh_private_keyfile(keyfile_path): + continue + cmd = ["ssh-keygen", "-lf", keyfile_path] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, _ = proc.communicate() + if proc.returncode == 0: + fingerprint = stdout.strip() + if fingerprint: + keys_in_home_ssh[fingerprint] = keyfile_path + return keys_in_home_ssh + def guess_keyfile(self): + keys_in_agent = self.list_ssh_agent_keys() + if keys_in_agent: + keys_in_home_ssh = self.list_ssh_dir_keys() + for fingerprint in keys_in_agent: + if fingerprint in keys_in_home_ssh: + return keys_in_home_ssh[fingerprint] sshdir = os.path.expanduser('~/.ssh') keyfiles = ('id_ed25519', 'id_rsa') for keyfile in keyfiles: From badcfc283c491334dd9fafcd829c650c8f315f5d Mon Sep 17 00:00:00 2001 From: Michael Schroeder Date: Mon, 16 May 2022 09:52:28 +0200 Subject: [PATCH 7/7] Remove no longer used modules --- osc/conf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/osc/conf.py b/osc/conf.py index c409e12c..ded53264 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -43,7 +43,6 @@ import os import re import sys import ssl -import warnings import getpass import time import subprocess @@ -63,7 +62,7 @@ except ImportError: from httplib import HTTPConnection, HTTPResponse from StringIO import StringIO from urlparse import urlsplit - from urllib2 import URLError, HTTPBasicAuthHandler, HTTPCookieProcessor, HTTPPasswordMgrWithDefaultRealm, ProxyHandler, AbstractBasicAuthHandler + from urllib2 import URLError, HTTPBasicAuthHandler, HTTPCookieProcessor, HTTPPasswordMgrWithDefaultRealm, ProxyHandler from urllib2 import AbstractHTTPHandler, build_opener, proxy_bypass, HTTPSHandler from urllib2 import BaseHandler, parse_keqv_list, parse_http_list