Merge lp:~allenap/maas/other-ssh-key-types--bug-1590081 into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 5118
Proposed branch: lp:~allenap/maas/other-ssh-key-types--bug-1590081
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 463 lines (+354/-24)
8 files modified
src/maasserver/models/sshkey.py (+5/-17)
src/maasserver/models/tests/test_sshkey.py (+28/-7)
src/maasserver/tests/data/test_ecdsa256.pub (+1/-0)
src/maasserver/tests/data/test_ecdsa384.pub (+1/-0)
src/maasserver/tests/data/test_ecdsa521.pub (+1/-0)
src/maasserver/tests/data/test_ed25519.pub (+1/-0)
src/provisioningserver/utils/sshkey.py (+161/-0)
src/provisioningserver/utils/tests/test_sshkey.py (+156/-0)
To merge this branch: bzr merge lp:~allenap/maas/other-ssh-key-types--bug-1590081
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+297093@code.launchpad.net

Commit message

Permit use of ECDSA and ED25519 SSH keys.

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

I would rather see this fixed upstream than hack around it in MAAS. I checked the twisted code[1] and saw that it was decoding the base64 and parsing the expected ASN1, which I agree is probably too much work for us to take on at this time.

But I'm not convinced that calling Twisted to validate the key for us was the right thing to do in the first place. After all, SSH would be better at telling us whether or not we have a valid key. We should do this instead:

    (1) Write key to <tempfile
    (2) Check the result of: ssh-keygen -l -f <tempfile>

[1]:
https://github.com/twisted/twisted/blob/trunk/twisted/conch/ssh/keys.py

review: Needs Fixing
Revision history for this message
Gavin Panella (allenap) wrote :

>     (1) Write key to <tempfile
>     (2) Check the result of: ssh-keygen -l -f <tempfile>

I like this idea, but I'm not sure that's going to reproduce the right
behaviour. The conch code is useful because it does check that the key
is sane *and* it's a public key. The last bit prevents users from saving
private keys, which would be a bit of a security disaster, and wouldn't
work for MAAS's purposes anyway. Even the hacky approach in this branch
can distinguish between public and private keys.

If we can figure out a way to get an answer to "is this a valid looking
public key" out of ssh-keygen or another OpenSSH program (library?) then
I'm in full agreement that we should use it.

Perhaps the way to do it is to first check, in Python, that the key
starts with {ssh-{rsa,dsa,ed25519},ecdsa-sha2-nistp{256,384,521}} and
then run it through ssh-keygen -l as well.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Good catch. A couple of options I can think of:

(1) Manually validate that the user didn't enter a private key (because they should know they just did something horribly wrong) - could check for PEM headers, like if the key starts with "-", contains the text "PRIVATE", etc.

(2) Use ssh-keygen to export and then re-import the key, thus normalizing it. (if they pasted in their private key, it would be converted to a public key) For example:

keygen -e -f id_rsa.pub > keyfile && ssh-keygen -i -f keyfle

Only problem is, this seems to lose the comment at the end.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

I meant ssh-keygen for the example command under (2), by the way. (and I ran it from ~/.ssh)

Revision history for this message
Mike Pontillo (mpontillo) wrote :

(and also, it outputs the same thing for both my id_rsa and id_rsa.pub, in case I wasn't clear.)

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Oh, and I guess you could run the key through "ssh-keygen -l -f <keyfile>" first to grab the comment. You'd have to parse the output; it seems to append the key type in parentheses after the comment, and spaces are allowed in the comment. So you'd just drop the first, second, and last fields, and keep everything in the middle.

Revision history for this message
Gavin Panella (allenap) wrote :

I've read and read today and I think we should do what we can to ensure
we have a public key first (and not try to extract a public key from a
private key) and then pump it through ssh-keygen to ensure it's valid.

sshd(8) has a section describing the format of ~/.ssh/authorized_keys:

  Each line of the file contains one key (empty lines and lines starting
  with a ‘#’ are ignored as comments). 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. The options field is optional; [...]. The
  bits, exponent, modulus, and comment fields give the RSA key for
  protocol version 1; the comment field is not used for anything (but
  may be convenient for the user to identify the key). For protocol
  version 2 the keytype is “ecdsa-sha2-nistp256”, “ecdsa-sha2-nistp384”,
  “ecdsa-sha2-nistp521”, “ssh-ed25519”, “ssh-dss” or “ssh-rsa”.

ssh-keygen(1) explicitly recommends appending public key files to
~/.ssh/authorized_keys:

  The contents ... should be added to ~/.ssh/authorized_keys on all
  machines where the user wishes to log in using public key
  authentication.

Marrying the two we have official documentation for the format of public
key files!

We should ignore protocol 1 keys. I can't even create an rsa1 key on
Xenial:

  $ ssh-keygen -t rsa1
  Generating public/private rsa1 key pair.
  Enter file in which to save the key (.../.ssh/identity):
  Enter passphrase (empty for no passphrase):
  Enter same passphrase again:
  Saving key ".../.ssh/identity" failed: unknown or unsupported key type

Although ~/.ssh/authorized_keys can contain options, we should assume
that the public keys pasted into MAAS do not have options.

Given all that I propose the following:

1. Check there are 2 or 3 fields: keytype base64-encoded-key [comment]

2. Run through `setsid -w ssh-keygen -e -f $keyfile > $intermediate <&-`.

3. Run through `setsid -w ssh-keygen -i -f $intermediate > $pubkey <&-`.

Note: setsid and <&- ensures ssh-keygen doesn't using the caller's TTY.
This'll be implemented in Python with no recourse to a shell, so the
commands won't look like those above, but behaviour will be preserved.

4. $pubkey should contain two fields: keytype, base64-encoded key.

5. Reunite $pubkey with comment, if there was one.

Errors from ssh-keygen at any point should be reported *with the error
message*. Currently all errors relating to SSH keys are coalesced into
the same static message.

Revision history for this message
Gavin Panella (allenap) wrote :

> Oh, and I guess you could run the key through "ssh-keygen -l -f
> <keyfile>" first to grab the comment. You'd have to parse the output;
> it seems to append the key type in parentheses after the comment, and
> spaces are allowed in the comment. So you'd just drop the first,
> second, and last fields, and keep everything in the middle.

There's no official documentation for the output of `ssh-keygen -l` so
I'd rather parse the given key because we do have some kind of
documentation for what that ought to look like.

Revision history for this message
Gavin Panella (allenap) wrote :

I forgot something:

> Given all that I propose the following:
>
> 1. Check there are 2 or 3 fields: keytype base64-encoded-key [comment]

1a. Check that keytype is one of “ecdsa-sha2-nistp256”,
“ecdsa-sha2-nistp384”, “ecdsa-sha2-nistp521”, “ssh-ed25519”, “ssh-dss”
or “ssh-rsa”.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

I think all that sounds good. But again, I would rather not hard-code the expected 'keytype' values in our Python code. Don't you think that if the export/import cycle succeeds, that's a strong indication that `keytype` is valid?

Revision history for this message
Gavin Panella (allenap) wrote :

> I think all that sounds good. But again, I would rather not hard-code
> the expected 'keytype' values in our Python code. Don't you think that
> if the export/import cycle succeeds, that's a strong indication that
> `keytype` is valid?

I would agree with that but for one problem: ssh-keygen gives terrible
error messages. For example, given a valid key but with a bogus key
type, ssh-keygen prints:

  Load key "/path/to/key": incorrect passphrase supplied to decrypt
  private key

This message is also given for many other issues. Initially I did want
to expose ssh-keygen's error messages in the exception raised, and
ultimately in the UI, but so far they seem to be universally awful.

My preference would be to stick with the set of key types so that we can
produce better error messages. Key types are a slow moving target and we
can respond quickly with a small update to OPENSSH_PROTOCOL2_KEY_TYPES
and some test data.

Including recognised key types in the error message also means the user
can help him/herself by choosing a different key. They can see that the
deficiency is in MAAS and not their key, so they're more likely to file
a bug report rather than bad-mouth MAAS for not working.

Revision history for this message
Gavin Panella (allenap) wrote :

BTW, I've implemented it with ssh-keygen now and it's ready to be reviewed again.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Gavin, I have tested out the new code and it's working great.

Just one regression to fix before it can land: spaces are supported in SSH key comments.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Another nice-to-have validation would be to reject duplicate keys based on the (type, value) ignoring the comment. (right now it seems we do an exact match, which means I can add the same key twice with a different comment.) But I guess that's out of scope for this branch.

Revision history for this message
Gavin Panella (allenap) wrote :

Thanks Mike. I've fixed the comments-with-whitespace issue, but I agree that de-duplicating on keytype+key is out of scope here.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Looks great. Thanks for all the fixes.

One thing I won't block you on: it looks like you're missing a test for comments-with-spaces? Unless I just missed it.

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

Yep, as discussed, I sneaked one in as test_normalises_mixed_whitespace_in_comments. Thanks again!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/models/sshkey.py'
--- src/maasserver/models/sshkey.py 2016-04-11 16:23:26 +0000
+++ src/maasserver/models/sshkey.py 2016-06-14 15:28:23 +0000
@@ -18,13 +18,10 @@
18 TextField,18 TextField,
19)19)
20from django.utils.safestring import mark_safe20from django.utils.safestring import mark_safe
21from maasserver import (21from maasserver import DefaultMeta
22 DefaultMeta,
23 logger,
24)
25from maasserver.models.cleansave import CleanSave22from maasserver.models.cleansave import CleanSave
26from maasserver.models.timestampedmodel import TimestampedModel23from maasserver.models.timestampedmodel import TimestampedModel
27from twisted.conch.ssh.keys import Key24from provisioningserver.utils.sshkey import normalise_openssh_public_key
2825
2926
30class SSHKeyManager(Manager):27class SSHKeyManager(Manager):
@@ -38,18 +35,9 @@
38def validate_ssh_public_key(value):35def validate_ssh_public_key(value):
39 """Validate that the given value contains a valid SSH public key."""36 """Validate that the given value contains a valid SSH public key."""
40 try:37 try:
41 key = Key.fromString(value.encode("utf-8"))38 return normalise_openssh_public_key(value)
42 except Exception:39 except Exception as error:
43 # twisted.conch.ssh.keys.Key.fromString raises all sorts of exceptions.40 raise ValidationError("Invalid SSH public key: " + str(error))
44 # Here, we catch them all and return a ValidationError since this
45 # method only aims at validating keys and not return the exact cause of
46 # the failure.
47 logger.exception("Invalid SSH public key")
48 raise ValidationError("Invalid SSH public key.")
49 else:
50 if not key.isPublic():
51 raise ValidationError(
52 "Invalid SSH public key (this key is a private key).")
5341
5442
55HELLIPSIS = '&hellip;'43HELLIPSIS = '&hellip;'
5644
=== modified file 'src/maasserver/models/tests/test_sshkey.py'
--- src/maasserver/models/tests/test_sshkey.py 2016-05-12 19:07:37 +0000
+++ src/maasserver/models/tests/test_sshkey.py 2016-06-14 15:28:23 +0000
@@ -1,4 +1,4 @@
1# Copyright 2012-2015 Canonical Ltd. This software is licensed under the1# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for the SSHKey model."""4"""Tests for the SSHKey model."""
@@ -6,16 +6,17 @@
6__all__ = []6__all__ = []
77
8import random8import random
9from unittest.mock import Mock
109
11from django.core.exceptions import ValidationError10from django.core.exceptions import ValidationError
12from django.db import IntegrityError11from django.db import IntegrityError
13from django.utils.safestring import SafeString12from django.utils.safestring import SafeString
14from maasserver.models import SSHKey13from maasserver.models import (
14 sshkey,
15 SSHKey,
16)
15from maasserver.models.sshkey import (17from maasserver.models.sshkey import (
16 get_html_display_for_key,18 get_html_display_for_key,
17 HELLIPSIS,19 HELLIPSIS,
18 Key,
19 validate_ssh_public_key,20 validate_ssh_public_key,
20)21)
21from maasserver.testing import get_data22from maasserver.testing import get_data
@@ -36,6 +37,26 @@
36 validate_ssh_public_key(key_string)37 validate_ssh_public_key(key_string)
37 # No ValidationError.38 # No ValidationError.
3839
40 def test_validates_ecdsa_curve256_public_key(self):
41 key_string = get_data('data/test_ecdsa256.pub')
42 validate_ssh_public_key(key_string)
43 # No ValidationError.
44
45 def test_validates_ecdsa_curve384_public_key(self):
46 key_string = get_data('data/test_ecdsa384.pub')
47 validate_ssh_public_key(key_string)
48 # No ValidationError.
49
50 def test_validates_ecdsa_curve521_public_key(self):
51 key_string = get_data('data/test_ecdsa521.pub')
52 validate_ssh_public_key(key_string)
53 # No ValidationError.
54
55 def test_validates_ed25519_public_key(self):
56 key_string = get_data('data/test_ed25519.pub')
57 validate_ssh_public_key(key_string)
58 # No ValidationError.
59
39 def test_does_not_validate_random_data(self):60 def test_does_not_validate_random_data(self):
40 key_string = factory.make_string()61 key_string = factory.make_string()
41 self.assertRaises(62 self.assertRaises(
@@ -57,9 +78,9 @@
57 ValidationError, validate_ssh_public_key, key_string)78 ValidationError, validate_ssh_public_key, key_string)
5879
59 def test_does_not_validate_wrong_key(self):80 def test_does_not_validate_wrong_key(self):
60 # If twisted.conch.ssh.keys.Key raises an exception, the validation81 # Validation fails if normalise_openssh_public_key crashes.
61 # fails.82 norm = self.patch(sshkey, "normalise_openssh_public_key")
62 self.patch(Key, 'fromString', Mock(side_effect=Exception()))83 norm.side_effect = factory.make_exception_type()
63 self.assertRaises(84 self.assertRaises(
64 ValidationError, validate_ssh_public_key, factory.make_name('key'))85 ValidationError, validate_ssh_public_key, factory.make_name('key'))
6586
6687
=== added file 'src/maasserver/tests/data/test_ecdsa256.pub'
--- src/maasserver/tests/data/test_ecdsa256.pub 1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/data/test_ecdsa256.pub 2016-06-14 15:28:23 +0000
@@ -0,0 +1,1 @@
1ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEqp6hJ9qj6dD1Y1AfsbauzjAaoIQhvTCdg+otLRklg5ZWr8KoS98K50s0eVwcOD7iLltCeS7W0y8c7wlsADVh0= foo@bar
02
=== added file 'src/maasserver/tests/data/test_ecdsa384.pub'
--- src/maasserver/tests/data/test_ecdsa384.pub 1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/data/test_ecdsa384.pub 2016-06-14 15:28:23 +0000
@@ -0,0 +1,1 @@
1ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBFnB+h79/2MeUR4FoDuKJDyjLEswi8I50NuwIoRbHOwPkPDSDXk6EKfBY0GEwAGyr7h9OjVlmA1KKWUE01KJKf4/iJOh+9zsaL4iQzP9Q9phiUAmxkvegefGwqEXeAvk1Q== foo@bar
02
=== added file 'src/maasserver/tests/data/test_ecdsa521.pub'
--- src/maasserver/tests/data/test_ecdsa521.pub 1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/data/test_ecdsa521.pub 2016-06-14 15:28:23 +0000
@@ -0,0 +1,1 @@
1ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAFid8WJ6720Z8xJ/Fnsz9eZmUxdbcVNzBeML380gMeBMP9zPXWz629cahQT0HncnKsLsbRB7MMxdaBdsAQ8pteGXQEHVdnr6IkOrVbCHtVaVbjN4gpRICseMnDHrryrOjsvBIU7GGpmmHZka9alvSZlbB1lCx1BxqZZj8AHjJq2KpUh+A== foo@bar
02
=== added file 'src/maasserver/tests/data/test_ed25519.pub'
--- src/maasserver/tests/data/test_ed25519.pub 1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/data/test_ed25519.pub 2016-06-14 15:28:23 +0000
@@ -0,0 +1,1 @@
1ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBEqkw2AgmkjqNjCFuiKXeUgLNmRbgVr8W2TlAvFybJv foo@bar
02
=== added file 'src/provisioningserver/utils/sshkey.py'
--- src/provisioningserver/utils/sshkey.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/utils/sshkey.py 2016-06-14 15:28:23 +0000
@@ -0,0 +1,161 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Utilities for working with OpenSSH keys."""
5
6__all__ = [
7 "normalise_openssh_public_key",
8 "OpenSSHKeyError",
9]
10
11from itertools import chain
12import os
13from pathlib import Path
14import pipes
15from subprocess import (
16 CalledProcessError,
17 check_output,
18 PIPE,
19)
20from tempfile import TemporaryDirectory
21
22from provisioningserver.utils.shell import select_c_utf8_locale
23
24
25OPENSSH_PROTOCOL2_KEY_TYPES = frozenset((
26 "ecdsa-sha2-nistp256",
27 "ecdsa-sha2-nistp384",
28 "ecdsa-sha2-nistp521",
29 "ssh-dss",
30 "ssh-ed25519",
31 "ssh-rsa",
32))
33
34
35class OpenSSHKeyError(ValueError):
36 """The given key was not recognised or was corrupt."""
37
38
39def normalise_openssh_public_key(keytext):
40 """Validate and normalise an OpenSSH public key.
41
42 Essentially: ensure we have a public key first (and not try to extract a
43 public key from a private key) and then pump it through an ssh-keygen(1)
44 pipeline to ensure it's valid.
45
46 sshd(8) has a section describing the format of ~/.ssh/authorized_keys:
47
48 Each line of the file contains one key (empty lines and lines starting
49 with a ‘#’ are ignored as comments). Protocol 1 public keys consist of
50 the following space-separated fields: options, bits, exponent, modulus,
51 comment. Protocol 2 public key consist of: options, keytype,
52 base64-encoded key, comment. The options field is optional; [...]. The
53 bits, exponent, modulus, and comment fields give the RSA key for
54 protocol version 1; the comment field is not used for anything (but may
55 be convenient for the user to identify the key). For protocol version 2
56 the keytype is “ecdsa-sha2-nistp256”, “ecdsa-sha2-nistp384”,
57 “ecdsa-sha2-nistp521”, “ssh-ed25519”, “ssh-dss” or “ssh-rsa”.
58
59 ssh-keygen(1) explicitly recommends appending public key files to
60 ~/.ssh/authorized_keys:
61
62 The contents ... should be added to ~/.ssh/authorized_keys on all
63 machines where the user wishes to log in using public key
64 authentication.
65
66 Marrying the two we have official documentation for the format of public
67 key files!
68
69 We should ignore protocol 1 keys. It does not even appear to be possible
70 to create an rsa1 key on Xenial:
71
72 $ ssh-keygen -t rsa1
73 Generating public/private rsa1 key pair.
74 Enter file in which to save the key (.../.ssh/identity):
75 Enter passphrase (empty for no passphrase):
76 Enter same passphrase again:
77 Saving key ".../.ssh/identity" failed: unknown or unsupported key type
78
79 Although ~/.ssh/authorized_keys can contain options, we should assume that
80 the public keys pasted into MAAS do not have options. Public key files
81 generated by ssh-keygen(1) will not contain options.
82
83 Given all that, this function does the following:
84
85 1. Checks there are 2 or more fields: keytype base64-encoded-key [comment]
86 (the comment can contain whitespace).
87
88 2. Checks that keytype is one of “ssh-dss”, “ssh-rsa”, “ssh-ed25519”,
89 “ecdsa-sha2-nistp256”, “ecdsa-sha2-nistp384”, or “ecdsa-sha2-nistp521”,
90
91 2. Run through `setsid -w ssh-keygen -e -f $keyfile > $intermediate <&-`.
92
93 3. Run through `setsid -w ssh-keygen -i -f $intermediate > $pubkey <&-`.
94
95 Note: setsid and <&- ensures ssh-keygen doesn't use the caller's TTY. This
96 is Python, and no recourse to a shell is being taken, but it has similar
97 behaviour.
98
99 4. $pubkey should contain two fields: keytype, base64-encoded key.
100
101 5. Reunite $pubkey with comment, if there was one.
102
103 Errors from ssh-keygen(1) at any point should be reported *with the error
104 message*. Previously all errors relating to SSH keys were coalesced into
105 the same static message.
106
107 """
108 parts = keytext.split()
109 if len(parts) >= 2:
110 keytype, key, *comments = parts
111 else:
112 raise OpenSSHKeyError(
113 "Key should contain 2 or more space separated parts (key type, "
114 "base64-encoded key, optional comments), not %d: %s" % (
115 len(parts), " ".join(map(pipes.quote, parts))))
116
117 if keytype not in OPENSSH_PROTOCOL2_KEY_TYPES:
118 raise OpenSSHKeyError(
119 "Key type %s not recognised; it should be one of: %s" % (
120 pipes.quote(keytype), " ".join(
121 sorted(OPENSSH_PROTOCOL2_KEY_TYPES))))
122
123 env = select_c_utf8_locale()
124 # Request OpenSSH to use /bin/true when prompting for passwords. We also
125 # have to redirect stdin from, say, /dev/null so that it doesn't use the
126 # terminal (when this is executed from a terminal).
127 env["SSH_ASKPASS"] = "/bin/true"
128
129 with TemporaryDirectory(prefix="maas") as tempdir:
130 keypath = Path(tempdir).joinpath("intermediate")
131 # Ensure that this file is locked-down.
132 keypath.touch()
133 keypath.chmod(0o600)
134 # Convert given key to RFC4716 form.
135 keypath.write_text("%s %s" % (keytype, key), "utf-8")
136 try:
137 with open(os.devnull, "r") as devnull:
138 rfc4716key = check_output(
139 ("setsid", "-w", "ssh-keygen", "-e", "-f", keypath.path),
140 stdin=devnull, stderr=PIPE, env=env)
141 except CalledProcessError:
142 raise OpenSSHKeyError(
143 "Key could not be converted to RFC4716 form.")
144 # Convert RFC4716 back to OpenSSH format public key.
145 keypath.write_bytes(rfc4716key)
146 try:
147 with open(os.devnull, "r") as devnull:
148 opensshkey = check_output(
149 ("setsid", "-w", "ssh-keygen", "-i", "-f", keypath.path),
150 stdin=devnull, stderr=PIPE, env=env)
151 except CalledProcessError:
152 # If this happens it /might/ be an OpenSSH bug. If we've managed
153 # to convert to RFC4716 form then it seems reasonable to assume
154 # that OpenSSH has already given this key its blessing.
155 raise OpenSSHKeyError(
156 "Key could not be converted from RFC4716 form to "
157 "OpenSSH public key form.")
158 else:
159 keytype, key = opensshkey.decode("utf-8").split()
160
161 return " ".join(chain((keytype, key), comments))
0162
=== added file 'src/provisioningserver/utils/tests/test_sshkey.py'
--- src/provisioningserver/utils/tests/test_sshkey.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/utils/tests/test_sshkey.py 2016-06-14 15:28:23 +0000
@@ -0,0 +1,156 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for `provisioningserver.utils.sshkey`."""
5
6__all__ = []
7
8import re
9
10from maastesting.factory import factory
11from maastesting.matchers import DocTestMatches
12from maastesting.testcase import MAASTestCase
13from provisioningserver.utils.sshkey import (
14 normalise_openssh_public_key,
15 OpenSSHKeyError,
16)
17from testtools.matchers import Equals
18
19
20example_openssh_public_keys = {
21 "ecdsa256": (
22 "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAA"
23 "ABBBEqp6hJ9qj6dD1Y1AfsbauzjAaoIQhvTCdg+otLRklg5ZWr8KoS98K50s0eVwcOD7i"
24 "LltCeS7W0y8c7wlsADVh0= ec2@bar"
25 ),
26 "ecdsa384": (
27 "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAA"
28 "ABhBFnB+h79/2MeUR4FoDuKJDyjLEswi8I50NuwIoRbHOwPkPDSDXk6EKfBY0GEwAGyr7"
29 "h9OjVlmA1KKWUE01KJKf4/iJOh+9zsaL4iQzP9Q9phiUAmxkvegefGwqEXeAvk1Q== "
30 "ec3@bar"
31 ),
32 "ecdsa521": (
33 "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAA"
34 "ACFBAFid8WJ6720Z8xJ/Fnsz9eZmUxdbcVNzBeML380gMeBMP9zPXWz629cahQT0HncnK"
35 "sLsbRB7MMxdaBdsAQ8pteGXQEHVdnr6IkOrVbCHtVaVbjN4gpRICseMnDHrryrOjsvBIU"
36 "7GGpmmHZka9alvSZlbB1lCx1BxqZZj8AHjJq2KpUh+A== ec5@bar"
37 ),
38 "dsa": (
39 "ssh-dss AAAAB3NzaC1kc3MAAACBALl8PCMaSa3pCCGJaJr4kH0QPlrgyG3Lka+/y4xx1"
40 "dOuJhpsLe2V9+CKX7Sz1yphCs26KqMFe/ebYGAUDhTdVlE4/TgpAP4GiTjdO1FGXTYdgQ"
41 "yJpfp50bTUW0zKIP/dwHs5dCLn4XYAxXzSsvORGVQGbM6P6vh3lieTkeVETGZDAAAAFQC"
42 "AaBKUmPvRqI37VRj1PE9B2rnkfQAAAIEApWYMF0IU+BYUtFuwRRUE9wEGxDEjTtuoWYCW"
43 "ML7Zn+cFOvK+C0x8YItQ3xIiI3a/0DCoDPIZPvImXDMrs0zUunegndS9g7J0gCHFY9dd+"
44 "rgYShUHwCI+hy/D9Dp1ukNnGD0bb3x5vEoSK6whrJWBM6is7TW4R5fvz/xDhrtIcxgAAA"
45 "CBAJbZsmuuWN2kb7lD27IzKcOgd07esoHPWZnv4qg7xhS1GdVr485v73OW1rfpWU6Pdoh"
46 "ckXLg9ZaoWtVTwNKTfHxS3iug9/pseBWTHdpmxCM5ClsZJii6T4frR5NTOCGKLxOamTs/"
47 "//OXopZr5u3vT20NFlzFE95J86tGtxYPPivx ubuntu@server-7476"
48 ),
49 "ed25519": (
50 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBEqkw2AgmkjqNjCFuiKXeUgLNmRbgVr8"
51 "W2TlAvFybJv ed255@bar"
52 ),
53 "rsa": (
54 "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDdrzzDZNwyMVBvBTT6kBnrfPZv/AUbk"
55 "xj7G5CaMTdw6xkKthV22EntD3lxaQxRKzQTfCc2d/CC1K4ushCcRs1S6SQ2zJ2jDq1UmO"
56 "UkDMgvNh4JVhJYSKc6mu8i3s7oGSmBado5wvtlpSzMrscOpf8Qe/wmT5fH12KB9ipJqoF"
57 "NQMVbVcVarE/v6wpn3GZC62YRb5iaz9/M+t92Qhu50W2u+KfouqtKB2lwIDDKZMww38Ex"
58 "tdMouh2FZpxaoh4Uey5bRp3tM3JgnWcX6fyUOp2gxJRPIlD9rrZhX5IkEkZM8MQbdPTQL"
59 "gIf98oFph5RG6w1t02BvI9nJKM7KkKEfBHt ubuntu@test_rsa0"
60 ),
61}
62
63
64def remove_comment(key):
65 """Remove the comment field from an OpenSSH public key.
66
67 Preserves leading and intermediate whitespace where reasonable.
68 """
69 match = re.match(r"\s*\S+\s+\S+", key)
70 assert match is not None, "Could not find keytype and key in %r" % (key,)
71 return match.group(0)
72
73
74class TestNormaliseOpenSSHPublicKeyBasics(MAASTestCase):
75 """Tests for `normalise_openssh_public_key`."""
76
77 def test_rejects_keys_with_fewer_than_2_parts(self):
78 example_key = factory.make_name("key")
79 error = self.assertRaises(
80 OpenSSHKeyError, normalise_openssh_public_key, example_key)
81 self.assertThat(str(error), Equals(
82 "Key should contain 2 or more space separated parts (key type, "
83 "base64-encoded key, optional comments), not 1: " + example_key))
84
85
86class _TestNormaliseOpenSSHPublicKeyCommon:
87 """Mix-in tests for `normalise_openssh_public_key`.
88
89 Providing tests that are common to keys with and without comments.
90 """
91
92 def test_roundtrip(self):
93 self.assertThat(
94 normalise_openssh_public_key(self.key),
95 Equals(self.key))
96
97 def test_rejects_keys_of_unrecognised_type(self):
98 _, rest = self.key.split(None, 1)
99 example_type = factory.make_name("type")
100 example_key = example_type + " " + rest
101 error = self.assertRaises(
102 OpenSSHKeyError, normalise_openssh_public_key, example_key)
103 self.assertThat(str(error), DocTestMatches(
104 "Key type " + example_type + " not recognised; it should be "
105 "one of: ... ssh-dss ..."))
106
107 def test_rejects_corrupt_keys(self):
108 parts = self.key.split()
109 parts[1] = parts[1][:-1] # Remove one character from the key.
110 example_key = " ".join(parts)
111 error = self.assertRaises(
112 OpenSSHKeyError, normalise_openssh_public_key, example_key)
113 self.assertThat(str(error), Equals(
114 "Key could not be converted to RFC4716 form."))
115
116
117class TestNormaliseOpenSSHPublicKeyWithComments(
118 _TestNormaliseOpenSSHPublicKeyCommon, MAASTestCase):
119 """Tests for `normalise_openssh_public_key` for keys with comments."""
120
121 scenarios = sorted(
122 (name, dict(key=key)) for name, key in
123 example_openssh_public_keys.items()
124 )
125
126 def test_normalises_mixed_whitespace(self):
127 parts = self.key.split()
128 example_key = " %s \t %s\n %s\r\n" % tuple(parts)
129 self.assertThat(
130 normalise_openssh_public_key(example_key),
131 Equals(self.key))
132
133 def test_normalises_mixed_whitespace_in_comments(self):
134 extra_comments = factory.make_name("foo"), factory.make_name("bar")
135 example_key = self.key + " \t " + " \n\r ".join(extra_comments) + "\n"
136 expected_key = self.key + " " + " ".join(extra_comments)
137 self.assertThat(
138 normalise_openssh_public_key(example_key),
139 Equals(expected_key))
140
141
142class TestNormaliseOpenSSHPublicKeyWithoutComments(
143 _TestNormaliseOpenSSHPublicKeyCommon, MAASTestCase):
144 """Tests for `normalise_openssh_public_key` for keys without comments."""
145
146 scenarios = sorted(
147 (name, dict(key=remove_comment(key))) for name, key in
148 example_openssh_public_keys.items()
149 )
150
151 def test_normalises_mixed_whitespace(self):
152 parts = self.key.split()
153 example_key = " %s \t %s\r\n" % tuple(parts)
154 self.assertThat(
155 normalise_openssh_public_key(example_key),
156 Equals(self.key))