From 870d861b61aeb07af8eb33b034819138a6b8d847 Mon Sep 17 00:00:00 2001 From: Martin Wilck Date: Thu, 2 Jun 2022 21:56:51 +0200 Subject: [PATCH 1/2] ssh: recognize gpg keys (yubikey usage) When using ssh keys from gpg, there are no private key files on disk. The public keys are available from "ssh-add -L". Conveniently, users store the public keys in some ".pub" file under ~/.ssh (see e.g. https://serverfault.com/questions/906871/force-the-use-of-a-gpg-key-as-an-ssh-key-for-a-given-server; this is also necessary to use IdentityFile= in ssh itself). Thus public key files can't be ignored any more in list_ssh_dir_keys(). "ssh-keygen -Y sign" works nicely with a public key file if the agent has access to the private key. --- osc/conf.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/osc/conf.py b/osc/conf.py index 54cb360f..18768c69 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -565,7 +565,7 @@ def _build_opener(apiurl): proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, _ = proc.communicate() if proc.returncode == 0 and stdout.strip(): - return stdout.splitlines() + return [self.get_fingerprint(line) for line in stdout.splitlines()] else: return [] @@ -584,21 +584,43 @@ def _build_opener(apiurl): return True return False + def is_ssh_public_keyfile(self, keyfile_path): + if not os.path.isfile(keyfile_path): + return False + return keyfile_path.endswith(".pub") + + @staticmethod + def get_fingerprint(line): + parts = line.strip().split(b" ") + if len(parts) < 2: + raise ValueError("Unable to retrieve ssh key fingerprint from line: {}".format(line)) + return parts[1] + 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"): + if keyfile.startswith(("agent-", "authorized_keys", "config", "known_hosts")): + # skip files that definitely don't contain keys continue + keyfile_path = os.path.join(sshdir, keyfile) - if not self.is_ssh_private_keyfile(keyfile_path): + # public key alone may be sufficient because the private key + # can get loaded into ssh-agent from gpg (yubikey works this way) + is_public = self.is_ssh_public_keyfile(keyfile_path) + # skip private detection if we think the key is a public one already + is_private = False if is_public else self.is_ssh_private_keyfile(keyfile_path) + + if not is_public and not is_private: 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: + fingerprint = self.get_fingerprint(stdout) + if fingerprint and (fingerprint not in keys_in_home_ssh or is_private): + # prefer path to a private key keys_in_home_ssh[fingerprint] = keyfile_path return keys_in_home_ssh From a7e5e12c5ad5f165625fda047220c5e5545be363 Mon Sep 17 00:00:00 2001 From: Daniel Mach Date: Fri, 8 Jul 2022 15:16:17 +0200 Subject: [PATCH 2/2] Allow users to prefer ssh key over password auth If `sshkey` config option is set, then osc prefers it over password auth. If `sshkey` config option is not set and the server supports both basic and signature auth, basic auth is used and ssh key is NOT auto-detected. Users who want to use ssh auth with ssh key auto-detection can now leave the `pass` config option empty to trigger ssh key auto-detection. The ssh-key autodetection picks the first key that matches: - key loaded to ssh-agent (`ssh-add -l`) that has a public key in ~/.ssh - ~/.ssh/{id_ed25519,id_rsa} It is also recommended to use Obfuscated or Plaintext credentials manager. Please be aware that storing passwords using these credentials managers is unsafe, because they're stored in plain text on disk. Example: [] user= pass= # ssh key is auto-detected because `pass` is empty sshkey= credentials_mgr_class=osc.credentials.ObfuscatedConfigFileCredentialsManager --- osc/conf.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/osc/conf.py b/osc/conf.py index 18768c69..decae35b 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -540,14 +540,26 @@ def _build_opener(apiurl): 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): + + if 'signature' in authreqs \ + and self.signatureauthhandler \ + and ( + # sshkey explicitly set in the config file, use it instead of doing basic auth + self.signatureauthhandler.sshkey_known() + or ( + # can't fall-back to basic auth, because server doesn't support it + 'basic' not in authreqs + # can't fall-back to basic auth, because there's no password provided + or not self.passwd.find_user_password(None, apiurl)[1] + )): 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'):