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/init.py | 4 +- gitosis/ssh.py | 36 ++------ gitosis/sshkey.py | 221 ++++++++++++++++++++++++++++++++++++++++++++ gitosis/test/test_ssh.py | 83 ----------------- gitosis/test/test_sshkey.py | 87 +++++++++++++++++ 5 files changed, 316 insertions(+), 115 deletions(-) create mode 100644 gitosis/sshkey.py create mode 100644 gitosis/test/test_sshkey.py diff --git a/gitosis/init.py b/gitosis/init.py index f1f8121..ffc0215 100644 --- a/gitosis/init.py +++ b/gitosis/init.py @@ -12,7 +12,7 @@ from ConfigParser import RawConfigParser from gitosis import repository from gitosis import run_hook -from gitosis import ssh +from gitosis import sshkey from gitosis import util from gitosis import app @@ -112,7 +112,7 @@ class Main(app.App): log.info('Reading SSH public key...') pubkey = read_ssh_pubkey() - user = ssh.extract_user(pubkey) + user = sshkey.extract_user(pubkey) if user is None: log.error('Cannot parse user from SSH public key.') sys.exit(1) diff --git a/gitosis/ssh.py b/gitosis/ssh.py index 10784fa..a9ed206 100644 --- a/gitosis/ssh.py +++ b/gitosis/ssh.py @@ -1,36 +1,14 @@ """ -Gitosis code to handle SSH public keys. +Gitosis code to handle SSH authorized_keys files """ import os, errno, re import logging +from gitosis import sshkey # C0103 - 'log' is a special name # pylint: disable-msg=C0103 log = logging.getLogger('gitosis.ssh') -_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) - -class InsecureSSHKeyUsername(Exception): - """Username contains not allowed characters""" - - def __str__(self): - return '%s: %s' % (self.__doc__, ': '.join(self.args)) - -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)) - def readKeys(keydir): """ Read SSH public keys from ``keydir/*.pub`` @@ -42,7 +20,7 @@ def readKeys(keydir): if ext != '.pub': continue - if not isSafeUsername(basename): + if not sshkey.isSafeUsername(basename): log.warn('Unsafe SSH username in keyfile: %r', filename) continue @@ -66,8 +44,6 @@ def generateAuthorizedKeys(keys): for (user, key) in keys: yield TEMPLATE % dict(user=user, key=key) -#Protocol 1 public keys consist of the following space-separated fields: options, bits, exponent, modulus, comment. -#Protocol 2 public key consist of: options, keytype, base64-encoded key, comment. _COMMAND_OPTS_SAFE_CMD = \ 'command="(/[^ "]+/)?gitosis-serve [^"]+"' _COMMAND_OPTS_SAFE = \ @@ -83,9 +59,9 @@ _COMMAND_OPTS_UNSAFE = \ +'|tunnel="[^"]+"' _COMMAND_RE = re.compile( - '^'+_COMMAND_OPTS_SAFE_CMD \ - +'(,('+_COMMAND_OPTS_SAFE+'))+' \ - +' .*') +'^'+_COMMAND_OPTS_SAFE_CMD \ ++'(,('+_COMMAND_OPTS_SAFE+'))+' \ ++' .*') def filterAuthorizedKeys(fp): """ 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# diff --git a/gitosis/test/test_ssh.py b/gitosis/test/test_ssh.py index d9ec2bd..77d7863 100644 --- a/gitosis/test/test_ssh.py +++ b/gitosis/test/test_ssh.py @@ -191,86 +191,3 @@ baz got = readFile(path) eq(got, '''# foo\nbar\nbaz\n### autogenerated by gitosis, DO NOT EDIT\ncommand="gitosis-serve jdoe",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %(key_1)s\n''' % dict(key_1=KEY_1)) -def test_ssh_extract_user_simple(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') - eq(got, 'fakeuser@fakehost') - -def test_ssh_extract_user_domain(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost.example.com') - eq(got, 'fakeuser@fakehost.example.com') - -def test_ssh_extract_user_domain_dashes(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@ridiculously-long.example.com') - eq(got, 'fakeuser@ridiculously-long.example.com') - -def test_ssh_extract_user_underscore(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake_user@example.com') - eq(got, 'fake_user@example.com') - -def test_ssh_extract_user_dot(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u.ser@example.com') - eq(got, 'fake.u.ser@example.com') - -def test_ssh_extract_user_dash(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u-ser@example.com') - eq(got, 'fake.u-ser@example.com') - -def test_ssh_extract_user_no_at(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser') - eq(got, 'fakeuser') - -def test_ssh_extract_user_caps(): - got = ssh.extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= Fake.User@Domain.Example.Com') - eq(got, 'Fake.User@Domain.Example.Com') - -@raises(ssh.InsecureSSHKeyUsername) -def test_ssh_extract_user_bad(): - try: - ssh.extract_user( - 'ssh-somealgo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ER3%#@e%') - except ssh.InsecureSSHKeyUsername, e: - eq(str(e), "Username contains not allowed characters: 'ER3%#@e%'") - raise e diff --git a/gitosis/test/test_sshkey.py b/gitosis/test/test_sshkey.py new file mode 100644 index 0000000..f44e250 --- /dev/null +++ b/gitosis/test/test_sshkey.py @@ -0,0 +1,87 @@ +from nose.tools import eq_ as eq, assert_raises, raises + +from gitosis import sshkey + +def test_sshkey_extract_user_simple(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') + eq(got, 'fakeuser@fakehost') + +def test_sshkey_extract_user_domain(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost.example.com') + eq(got, 'fakeuser@fakehost.example.com') + +def test_sshkey_extract_user_domain_dashes(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@ridiculously-long.example.com') + eq(got, 'fakeuser@ridiculously-long.example.com') + +def test_sshkey_extract_user_underscore(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake_user@example.com') + eq(got, 'fake_user@example.com') + +def test_sshkey_extract_user_dot(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u.ser@example.com') + eq(got, 'fake.u.ser@example.com') + +def test_sshkey_extract_user_dash(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u-ser@example.com') + eq(got, 'fake.u-ser@example.com') + +def test_sshkey_extract_user_no_at(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser') + eq(got, 'fakeuser') + +def test_sshkey_extract_user_caps(): + got = sshkey.extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= Fake.User@Domain.Example.Com') + eq(got, 'Fake.User@Domain.Example.Com') + +@raises(sshkey.InsecureSSHKeyUsername) +def test_sshkey_extract_user_bad(): + try: + sshkey.extract_user( + 'ssh-somealgo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ER3%#@e%') + except sshkey.InsecureSSHKeyUsername, e: + eq(str(e), "Username contains not allowed characters: 'ER3%#@e%'") + raise e -- cgit v1.2.3