From 0c573bd3b377e5a2da0c8b7da9e4a18c9a6039ab Mon Sep 17 00:00:00 2001 From: "Robin H. Johnson" Date: Mon, 24 Dec 2007 02:11:57 -0800 Subject: Add module to deal specifically with SSH public keys properly. --- gitosis/sshkey.py | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 gitosis/sshkey.py (limited to 'gitosis/sshkey.py') diff --git a/gitosis/sshkey.py b/gitosis/sshkey.py new file mode 100644 index 0000000..d160b69 --- /dev/null +++ b/gitosis/sshkey.py @@ -0,0 +1,221 @@ +""" +Gitosis code to intelligently handle SSH public keys. + +""" +from shlex import shlex +from StringIO import StringIO +import re + +# The 'ecc' and 'ecdh' types are speculative, based on the Internet Draft +# http://www.ietf.org/internet-drafts/draft-green-secsh-ecc-02.txt +SSH_KEY_PROTO2_TYPES = ['ssh-dsa', + 'ssh-ecc', + 'ssh-ecdh', + 'ssh-rsa'] + +# These options must not have arguments +SSH_KEY_OPTS = ['no-agent-forwarding', + 'no-port-forwarding', + 'no-pty', + 'no-X11-forwarding'] +# These options require arguments +SSH_KEY_OPTS_WITH_ARGS = ['command', + 'environment', + 'from', + 'permitopen', + 'tunnel' ] + +class MalformedSSHKey(Exception): + """Malformed SSH public key""" + +class InsecureSSHKeyUsername(Exception): + """Username contains not allowed characters""" + def __str__(self): + return '%s: %s' % (self.__doc__, ': '.join(self.args)) + +class SSHPublicKey: + """Base class for representing an SSH public key""" + def __init__(self, opts, keydata, comment): + """Create a new instance.""" + self._options = opts + self._comment = comment + self._username = comment + _ = comment.split(None) + if len(_) > 1: + self._username = _[0] + _ = keydata + + @property + def options(self): + """Returns a dictionary of options used with the SSH public key.""" + return self._options + + @property + def comment(self): + """Returns the comment associated with the SSH public key.""" + return self._comment + + @property + def username(self): + """ + Returns the username from the comment, the first word of the comment. + """ + return self._username + + def options_string(self): + """Return the options array as a suitable string.""" + def _single_option(): + """Convert a single option to a usable string.""" + for (key, val) in self._options.items(): + _ = key + if val is not None: + _ += "=\"%s\"" % (val.replace('"', '\\"'), ) + yield _ + return ','.join(_single_option()) + + @property + def key(self): + """Abstract method""" + raise NotImplementedError() + + @property + def full_key(self): + """Return a full SSH public key line, as found in authorized_keys""" + options = self.options_string() + if len(options) > 0: + options += ' ' + return '%s%s %s' % (options, self.key, self.comment) + + def __str__(self): + return self.full_key + +class SSH1PublicKey(SSHPublicKey): + """Class for representing an SSH public key, protocol version 1""" + def __init__(self, opts, keydata, comment): + """Create a new instance.""" + SSHPublicKey.__init__(self, opts, keydata, comment) + (self._key_bits, + self._key_exponent, + self._key_modulus) = keydata.split(' ') + @property + def key(self): + """Return just the SSH key data, without options or comments.""" + return '%s %s %s' % (self._key_bits, + self._key_exponent, + self._key_modulus) + +class SSH2PublicKey(SSHPublicKey): + """Class for representing an SSH public key, protocol version 2""" + def __init__(self, opts, keydata, comment): + """Create a new instance.""" + SSHPublicKey.__init__(self, opts, keydata, comment) + (self._key_prefix, self._key_base64) = keydata.split(' ') + @property + def key(self): + """Return just the SSH key data, without options or comments.""" + return '%s %s' % (self._key_prefix, self._key_base64) + +def get_ssh_pubkey(line): + """Take an SSH public key, and return an object representing it.""" + (opts, keydata, comment) = _explode_ssh_key(line) + if keydata.startswith('ssh-'): + return SSH2PublicKey(opts, keydata, comment) + else: + return SSH1PublicKey(opts, keydata, comment) + +def _explode_ssh_key(line): + """ + Break apart a public-key line correct. + - Protocol 1 public keys consist of: + options, bits, exponent, modulus, comment. + - Protocol 2 public key consist of: + options, keytype, base64-encoded key, comment. + - For all options that take an argument, having a quote inside the argument + is valid, and should be in the file as '\"' + - Spaces are also valid in those arguments. + - Options must be seperated by commas. + Seperately return the options, key data and comment. + """ + opts = {} + shl = shlex(StringIO(line), None, True) + shl.wordchars += '-' + # Treat ',' as whitespace seperation the options + shl.whitespace += ',' + shl.whitespace_split = 1 + # Handle the options first + keydata = None + def _check_eof(tok): + """See if the end was nigh.""" + if tok == shl.eof: + raise MalformedSSHKey("Unexpected end of key") + while True: + tok = shl.get_token() + _check_eof(tok) + # This is the start of the actual key, protocol 1 + if tok.isdigit(): + keydata = tok + expected_key_args = 2 + break + # This is the start of the actual key, protocol 2 + if tok in SSH_KEY_PROTO2_TYPES: + keydata = tok + expected_key_args = 1 + break + if tok in SSH_KEY_OPTS: + opts[tok] = None + continue + if '=' in tok: + (tok, _) = tok.split('=', 1) + if tok in SSH_KEY_OPTS_WITH_ARGS: + opts[tok] = _ + continue + raise MalformedSSHKey("Unknown fragment %r" % (tok, )) + # Now handle the key + # Protocol 2 keys have only 1 argument besides the type + # Protocol 1 keys have 2 arguments after the bit-count. + shl.whitespace_split = 1 + while expected_key_args > 0: + _ = shl.get_token() + _check_eof(_) + keydata += ' '+_ + expected_key_args -= 1 + # Everything that remains is a comment + comment = '' + shl.whitespace = '' + while True: + _ = shl.get_token() + if _ == shl.eof or _ == None: + break + comment += _ + return (opts, keydata, comment) + +_ACCEPTABLE_USER_RE = re.compile( + r'^[a-zA-Z][a-zA-Z0-9_.-]*(@[a-zA-Z][a-zA-Z0-9.-]*)?$' + ) + +def isSafeUsername(user): + """Is the username safe to use a a filename? """ + match = _ACCEPTABLE_USER_RE.match(user) + return (match is not None) + +def extract_user(pubkey): + """Find the username for a given SSH public key line.""" + _, user = pubkey.rsplit(None, 1) + if isSafeUsername(user): + return user + else: + raise InsecureSSHKeyUsername(repr(user)) + +#X#key1 = 'no-X11-forwarding,command="x b c , d=e f \\"wham\\" \' +#before you go-go" +#ssh-rsa abc robbat2@foo foo\tbar#ignore' +#X#key2 = 'from=172.16.9.1 768 3 5 sam comment\tfoo' +#X#key3 = '768 3 5 commentfoo' +#X## 123456789 123456789 123456789 123456789 123456789 +#X#k = get_ssh_pubkey(key1) +#X#print 'opts=%r' % (k.options, ) +#X#print 'k=%r' % (k.key, ) +#X#print 'c=%r' % (k.comment, ) +#X#print 'u=%r' % (k.username, ) +#X#print k.full_key +#X# -- cgit v1.2.3