Merge lp:~axwalk/juju-core/ssh-fingerprint-platform-independent into lp:~go-bot/juju-core/trunk

Proposed by Andrew Wilkins
Status: Merged
Approved by: Andrew Wilkins
Approved revision: no longer in the source branch.
Merged at revision: 2190
Proposed branch: lp:~axwalk/juju-core/ssh-fingerprint-platform-independent
Merge into: lp:~go-bot/juju-core/trunk
Diff against target: 385 lines (+223/-47)
5 files modified
utils/ssh/authorisedkeys.go (+118/-14)
utils/ssh/authorisedkeys_test.go (+51/-0)
utils/ssh/fingerprint.go (+18/-20)
utils/ssh/fingerprint_test.go (+36/-0)
utils/ssh/fingerprint_win.go (+0/-13)
To merge this branch: bzr merge lp:~axwalk/juju-core/ssh-fingerprint-platform-independent
Reviewer Review Type Date Requested Status
Juju Engineering Pending
Review via email: mp+200980@code.launchpad.net

Commit message

utils/ssh: platform-independent KeyFingerprint

Windows CLI is broken as keyFingerprint has
not been implemented. We can't rely on
ssh-keygen there.

I've rewritten the keyFingerprint function in
pure Go. This involves writing a parser for
authorized_keys lines. The base64-decoded key
is MD5-summed, and then formatted with colons
separating the hex-encoded bytes.

https://codereview.appspot.com/49710043/

Description of the change

utils/ssh: platform-independent KeyFingerprint

Windows CLI is broken as keyFingerprint has
not been implemented. We can't rely on
ssh-keygen there.

I've rewritten the keyFingerprint function in
pure Go. This involves writing a parser for
authorized_keys lines. The base64-decoded key
is MD5-summed, and then formatted with colons
separating the hex-encoded bytes.

https://codereview.appspot.com/49710043/

To post a comment you must log in.
Revision history for this message
Andrew Wilkins (axwalk) wrote :

Reviewers: mp+200980_code.launchpad.net,

Message:
Please take a look.

Description:
utils/ssh: platform-independent KeyFingerprint

Windows CLI is broken as keyFingerprint has
not been implemented. We can't rely on
ssh-keygen there.

I've rewritten the keyFingerprint function in
pure Go. This involves writing a parser for
authorized_keys lines. The base64-decoded key
is MD5-summed, and then formatted with colons
separating the hex-encoded bytes.

https://code.launchpad.net/~axwalk/juju-core/ssh-fingerprint-platform-independent/+merge/200980

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/49710043/

Affected files (+186, -45 lines):
   A [revision details]
   M utils/ssh/authorisedkeys.go
   M utils/ssh/authorisedkeys_test.go
   M utils/ssh/fingerprint.go
   D utils/ssh/fingerprint_win.go

Revision history for this message
Tim Penhey (thumper) wrote :

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go
File utils/ssh/fingerprint.go (right):

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go#newcode4
utils/ssh/fingerprint.go:4: // +build !windows
Probably want to remove this line, yes?

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go#newcode15
utils/ssh/fingerprint.go:15: // in authorized_key format.
Is there an RFC to reference about the format of the fingerprint?

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go#newcode16
utils/ssh/fingerprint.go:16: func KeyFingerprint(key string)
(fingerprint, comment string, err error) {
Can we add a few tests?

https://codereview.appspot.com/49710043/

Revision history for this message
Andrew Wilkins (axwalk) wrote :

Please take a look.

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go
File utils/ssh/fingerprint.go (right):

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go#newcode4
utils/ssh/fingerprint.go:4: // +build !windows
On 2014/01/10 02:05:36, thumper wrote:
> Probably want to remove this line, yes?

Heh, yes, thanks.

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go#newcode15
utils/ssh/fingerprint.go:15: // in authorized_key format.
On 2014/01/10 02:05:36, thumper wrote:
> Is there an RFC to reference about the format of the fingerprint?

Apparently so: updated with reference to RFC4716.

https://codereview.appspot.com/49710043/diff/1/utils/ssh/fingerprint.go#newcode16
utils/ssh/fingerprint.go:16: func KeyFingerprint(key string)
(fingerprint, comment string, err error) {
On 2014/01/10 02:05:36, thumper wrote:
> Can we add a few tests?

I have added a direct test, though for the record, it's at 100% coverage
with the current (indirect) tests.

https://codereview.appspot.com/49710043/

Revision history for this message
Ian Booth (wallyworld) wrote :
Revision history for this message
Roger Peppe (rogpeppe) wrote :

Looks great in general; one thought below.

https://codereview.appspot.com/49710043/diff/20001/utils/ssh/authorisedkeys.go
File utils/ssh/authorisedkeys.go (right):

https://codereview.appspot.com/49710043/diff/20001/utils/ssh/authorisedkeys.go#newcode75
utils/ssh/authorisedkeys.go:75: if strings.HasPrefix(lower, o) {
I'm not sure that we need to know about all the
valid options and key types here. I think it
would be more robust (and future-proof) if we
could avoid that.

How about something like this:

Rename skipOptions to parseField, and make it return
the field instead of skipping it.

In ParseAuthorizedKeys, call parseField.
If it returns a field starting with a digit,
you know you've got a version 1 line and
can error out or parse the rest of the fields as appropriate.

Otherwise it's either the options or a key type.
If the options contains no comma or equals sign,
there's no fully unambiguous way of determining
which without knowing all the possible options
or key types, but I think it would be reasonable
to get the next field (which is either a key type
or a public key) and see if it's a public key.
Any public key is base64 and going to be reasonably big,
(if length > 30 || has any char other than [\-a-z0-9])
should be a reasonable diagnosis.

That way we can hopefully continue working without
change when ssh adds more options and/or key types.

https://codereview.appspot.com/49710043/

Revision history for this message
Andrew Wilkins (axwalk) wrote :

On 2014/01/10 09:31:19, rog wrote:
> Looks great in general; one thought below.

https://codereview.appspot.com/49710043/diff/20001/utils/ssh/authorisedkeys.go
> File utils/ssh/authorisedkeys.go (right):

https://codereview.appspot.com/49710043/diff/20001/utils/ssh/authorisedkeys.go#newcode75
> utils/ssh/authorisedkeys.go:75: if strings.HasPrefix(lower, o) {
> I'm not sure that we need to know about all the
> valid options and key types here. I think it
> would be more robust (and future-proof) if we
> could avoid that.

> How about something like this:

> Rename skipOptions to parseField, and make it return
> the field instead of skipping it.

> In ParseAuthorizedKeys, call parseField.
> If it returns a field starting with a digit,
> you know you've got a version 1 line and
> can error out or parse the rest of the fields as appropriate.

> Otherwise it's either the options or a key type.
> If the options contains no comma or equals sign,
> there's no fully unambiguous way of determining
> which without knowing all the possible options
> or key types, but I think it would be reasonable
> to get the next field (which is either a key type
> or a public key) and see if it's a public key.
> Any public key is base64 and going to be reasonably big,
> (if length > 30 || has any char other than [\-a-z0-9])
> should be a reasonable diagnosis.

> That way we can hopefully continue working without
> change when ssh adds more options and/or key types.

I did wonder about this, and you've swayed me. I really wasn't keen on
detecting the key based on something ambiguous, but I think you're right
and it's good enough. I will update the code when I finish off what I'm
currently doing.

https://codereview.appspot.com/49710043/

Revision history for this message
Andrew Wilkins (axwalk) wrote :

On 2014/01/10 10:10:38, hanwenn wrote:
> drive-by comment: have you considered adding some of this to gosshnew?
There is
> already some incomplete authorizedkey parsing in the SSH library.

Hi there!

I did think that it would be useful in a public library, but was going
for expediency here (we're using go.crypto/ssh at the moment; can't
really rely on gosshnew until it's nailed down). I can submit a patch to
gosshnew when my code is polished, and then we'll use it in the future
when your refactoring is done.

https://codereview.appspot.com/49710043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'utils/ssh/authorisedkeys.go'
2--- utils/ssh/authorisedkeys.go 2013-12-23 05:18:58 +0000
3+++ utils/ssh/authorisedkeys.go 2014-01-10 02:44:47 +0000
4@@ -4,6 +4,7 @@
5 package ssh
6
7 import (
8+ "encoding/base64"
9 "fmt"
10 "io/ioutil"
11 "os"
12@@ -33,9 +34,112 @@
13 authKeysFile = "authorized_keys"
14 )
15
16-// KeyFingerprint returns the fingerprint and comment for the specified key, using
17-// the OS dependent function keyFingerprint.
18-var KeyFingerprint = keyFingerprint
19+// case-insensive public authorized_keys options
20+var validOptions = [...]string{
21+ "cert-authority",
22+ "command",
23+ "environment",
24+ "from",
25+ "no-agent",
26+ "no-port-forwarding",
27+ "no-pty",
28+ "no-user",
29+ "no-X11-forwarding",
30+ "permitopen",
31+ "principals",
32+ "tunnel",
33+}
34+
35+var validKeytypes = [...]string{
36+ "ecdsa-sha2-nistp256",
37+ "ecdsa-sha2-nistp384",
38+ "ecdsa-sha2-nistp521",
39+ "ssh-dss",
40+ "ssh-rsa",
41+}
42+
43+type AuthorisedKey struct {
44+ KeyType string
45+ Key []byte
46+ Comment string
47+}
48+
49+// skipOptions takes a non-comment line from an
50+// authorized_keys file, and returns the remainder
51+// of the line after skipping any options at the
52+// beginning of the line.
53+func skipOptions(line string) string {
54+ found := false
55+ lower := strings.ToLower(line)
56+ for _, o := range validOptions {
57+ if strings.HasPrefix(lower, o) {
58+ line = line[len(o):]
59+ found = true
60+ break
61+ }
62+ }
63+ if !found {
64+ return line
65+ }
66+ // Skip to the next unquoted whitespace, returning the remainder.
67+ // Double quotes may be escaped with \".
68+ var quoted bool
69+ for i := 0; i < len(line); i++ {
70+ switch line[i] {
71+ case ' ', '\t':
72+ if !quoted {
73+ return strings.TrimLeft(line[i+1:], " \t")
74+ }
75+ case '\\':
76+ if i+1 < len(line) && line[i+1] == '"' {
77+ i++
78+ }
79+ case '"':
80+ quoted = !quoted
81+ }
82+ }
83+ return ""
84+}
85+
86+// ParseAuthorisedKey parses a non-comment line from an
87+// authorized_keys file and returns the constituent parts.
88+// Based on description in "man sshd".
89+//
90+// TODO(axw) support version 1 format?
91+func ParseAuthorisedKey(line string) (*AuthorisedKey, error) {
92+ withoutOptions := skipOptions(line)
93+ var keytype, key, comment string
94+ if i := strings.IndexAny(withoutOptions, " \t"); i == -1 {
95+ // There must be at least two fields: keytype and key.
96+ return nil, fmt.Errorf("malformed line: %q", line)
97+ } else {
98+ keytype = withoutOptions[:i]
99+ key = strings.TrimSpace(withoutOptions[i+1:])
100+ }
101+ validKeytype := false
102+ for _, kt := range validKeytypes {
103+ if keytype == kt {
104+ validKeytype = true
105+ break
106+ }
107+ }
108+ if !validKeytype {
109+ return nil, fmt.Errorf("invalid keytype %q in line %q", keytype, line)
110+ }
111+ // Split key/comment (if any)
112+ if i := strings.IndexAny(key, " \t"); i != -1 {
113+ key, comment = key[:i], key[i+1:]
114+ }
115+ keyBytes, err := base64.StdEncoding.DecodeString(key)
116+ if err != nil {
117+ return nil, err
118+ }
119+ return &AuthorisedKey{
120+ KeyType: keytype,
121+ Key: keyBytes,
122+ Comment: comment,
123+ }, nil
124+}
125
126 // SplitAuthorisedKeys extracts a key slice from the specified key data,
127 // by splitting the key data into lines and ignoring comments and blank lines.
128@@ -157,7 +261,7 @@
129 return err
130 }
131 for _, newKey := range newKeys {
132- fingerprint, comment, err := keyFingerprint(newKey)
133+ fingerprint, comment, err := KeyFingerprint(newKey)
134 if err != nil {
135 return err
136 }
137@@ -165,7 +269,7 @@
138 return fmt.Errorf("cannot add ssh key without comment")
139 }
140 for _, key := range existingKeys {
141- existingFingerprint, existingComment, err := keyFingerprint(key)
142+ existingFingerprint, existingComment, err := KeyFingerprint(key)
143 if err != nil {
144 // Only log a warning if the unrecognised key line is not a comment.
145 if key[0] != '#' {
146@@ -202,7 +306,7 @@
147 var sshKeys = make(map[string]string)
148 var keyComments = make(map[string]string)
149 for _, key := range existingKeyData {
150- fingerprint, comment, err := keyFingerprint(key)
151+ fingerprint, comment, err := KeyFingerprint(key)
152 if err != nil {
153 logger.Debugf("keeping unrecognised existing ssh key %q: %v", key, err)
154 keysToWrite = append(keysToWrite, key)
155@@ -248,13 +352,13 @@
156 }
157 var existingNonKeyLines []string
158 for _, line := range existingKeyData {
159- _, _, err := keyFingerprint(line)
160+ _, _, err := KeyFingerprint(line)
161 if err != nil {
162 existingNonKeyLines = append(existingNonKeyLines, line)
163 }
164 }
165 for _, newKey := range newKeys {
166- _, comment, err := keyFingerprint(newKey)
167+ _, comment, err := KeyFingerprint(newKey)
168 if err != nil {
169 return err
170 }
171@@ -275,7 +379,7 @@
172 }
173 var keys []string
174 for _, key := range keyData {
175- fingerprint, comment, err := keyFingerprint(key)
176+ fingerprint, comment, err := KeyFingerprint(key)
177 if err != nil {
178 // Only log a warning if the unrecognised key line is not a comment.
179 if key[0] != '#' {
180@@ -302,19 +406,19 @@
181 const JujuCommentPrefix = "Juju:"
182
183 func EnsureJujuComment(key string) string {
184- _, comment, err := keyFingerprint(key)
185+ ak, err := ParseAuthorisedKey(key)
186 // Just return an invalid key as is.
187 if err != nil {
188 logger.Warningf("invalid Juju ssh key %s: %v", key, err)
189 return key
190 }
191- if comment == "" {
192+ if ak.Comment == "" {
193 return key + " " + JujuCommentPrefix + "sshkey"
194 } else {
195 // Add the Juju prefix to the comment if necessary.
196- if !strings.HasPrefix(comment, JujuCommentPrefix) {
197- commentIndex := strings.LastIndex(key, comment)
198- return key[:commentIndex] + JujuCommentPrefix + comment
199+ if !strings.HasPrefix(ak.Comment, JujuCommentPrefix) {
200+ commentIndex := strings.LastIndex(key, ak.Comment)
201+ return key[:commentIndex] + JujuCommentPrefix + ak.Comment
202 }
203 }
204 return key
205
206=== modified file 'utils/ssh/authorisedkeys_test.go'
207--- utils/ssh/authorisedkeys_test.go 2013-12-23 05:18:58 +0000
208+++ utils/ssh/authorisedkeys_test.go 2014-01-10 02:44:47 +0000
209@@ -4,6 +4,7 @@
210 package ssh_test
211
212 import (
213+ "encoding/base64"
214 stdtesting "testing"
215
216 gc "launchpad.net/gocheck"
217@@ -234,3 +235,53 @@
218 c.Assert(actual, gc.DeepEquals, test.expected)
219 }
220 }
221+
222+func b64(s string) string {
223+ return base64.StdEncoding.EncodeToString([]byte(s))
224+}
225+
226+func (s *AuthorisedKeysKeysSuite) TestParseAuthorisedKey(c *gc.C) {
227+ for i, test := range []struct {
228+ line string
229+ keytype string
230+ key string
231+ comment string
232+ err string
233+ }{{
234+ line: "ssh-rsa " + b64("abc def"),
235+ keytype: "ssh-rsa",
236+ key: "abc def",
237+ }, {
238+ line: "ssh-dss " + b64("abc def"),
239+ keytype: "ssh-dss",
240+ key: "abc def",
241+ }, {
242+ line: "ssh-rsa " + b64("abc def") + " a b c",
243+ keytype: "ssh-rsa",
244+ key: "abc def",
245+ comment: "a b c",
246+ }, {
247+ line: "ssh-xsa blah",
248+ err: "invalid keytype \"ssh-xsa\" in line \"ssh-xsa blah\"",
249+ }, {
250+ // options should be skipped
251+ line: `no-pty,principals="\"",command="\!" ssh-rsa ` + b64("blah"),
252+ keytype: "ssh-rsa",
253+ key: "blah",
254+ }, {
255+ line: "ssh-rsa",
256+ err: "malformed line: \"ssh-rsa\"",
257+ }} {
258+ c.Logf("test %d: %s", i, test.line)
259+ ak, err := ssh.ParseAuthorisedKey(test.line)
260+ if test.err != "" {
261+ c.Assert(err, gc.ErrorMatches, test.err)
262+ } else {
263+ c.Assert(err, gc.IsNil)
264+ c.Assert(ak, gc.Not(gc.IsNil))
265+ c.Assert(ak.KeyType, gc.Equals, test.keytype)
266+ c.Assert(string(ak.Key), gc.Equals, test.key)
267+ c.Assert(ak.Comment, gc.Equals, test.comment)
268+ }
269+ }
270+}
271
272=== renamed file 'utils/ssh/fingerprint_notwin.go' => 'utils/ssh/fingerprint.go'
273--- utils/ssh/fingerprint_notwin.go 2013-12-09 03:09:14 +0000
274+++ utils/ssh/fingerprint.go 2014-01-10 02:44:47 +0000
275@@ -1,33 +1,31 @@
276 // Copyright 2013 Canonical Ltd.
277 // Licensed under the AGPLv3, see LICENCE file for details.
278-//
279-// +build !windows
280
281 package ssh
282
283 import (
284+ "bytes"
285+ "crypto/md5"
286 "fmt"
287- "launchpad.net/juju-core/utils"
288 )
289
290-// keyFingerprint returns the fingerprint and comment for the specified key.
291-// It calls ssh-keygen to do the work as there is no equivalent Go implementation.
292-func keyFingerprint(key string) (fingerprint, comment string, err error) {
293- // Instead of invoking ssh-keygen directly, it has to be called indirectly via a
294- // shell command because it doesn't read from stdin properly.
295- // See https://bugzilla.mindrot.org/show_bug.cgi?id=1477
296- shellCmd := fmt.Sprintf("ssh-keygen -lf /dev/stdin <<<'%s'", key)
297- output, err := utils.RunCommand("bash", "-c", shellCmd)
298+// KeyFingerprint returns the fingerprint and comment for the specified key
299+// in authorized_key format. Fingerprints are generated according to RFC4716.
300+// See ttp://www.ietf.org/rfc/rfc4716.txt, section 4.
301+func KeyFingerprint(key string) (fingerprint, comment string, err error) {
302+ ak, err := ParseAuthorisedKey(key)
303 if err != nil {
304 return "", "", fmt.Errorf("generating key fingerprint: %v", err)
305 }
306- var ignore string
307- n, err := fmt.Sscanf(output, "%s %s %s", &ignore, &fingerprint, &comment)
308- if n < 3 {
309- return "", "", fmt.Errorf("unexpected ssh-keygen output %q: %v", output, err)
310- }
311- if comment == "/dev/stdin" {
312- comment = ""
313- }
314- return fingerprint, comment, nil
315+ hash := md5.New()
316+ hash.Write(ak.Key)
317+ sum := hash.Sum(nil)
318+ var buf bytes.Buffer
319+ for i := 0; i < hash.Size(); i++ {
320+ if i > 0 {
321+ buf.WriteByte(':')
322+ }
323+ buf.WriteString(fmt.Sprintf("%02x", sum[i]))
324+ }
325+ return buf.String(), ak.Comment, nil
326 }
327
328=== added file 'utils/ssh/fingerprint_test.go'
329--- utils/ssh/fingerprint_test.go 1970-01-01 00:00:00 +0000
330+++ utils/ssh/fingerprint_test.go 2014-01-10 02:44:47 +0000
331@@ -0,0 +1,36 @@
332+// Copyright 2014 Canonical Ltd.
333+// Licensed under the AGPLv3, see LICENCE file for details.
334+
335+package ssh_test
336+
337+import (
338+ gc "launchpad.net/gocheck"
339+
340+ "launchpad.net/juju-core/testing/testbase"
341+ "launchpad.net/juju-core/utils/ssh"
342+ sshtesting "launchpad.net/juju-core/utils/ssh/testing"
343+)
344+
345+type FingerprintSuite struct {
346+ testbase.LoggingSuite
347+}
348+
349+var _ = gc.Suite(&FingerprintSuite{})
350+
351+func (s *FingerprintSuite) TestKeyFingerprint(c *gc.C) {
352+ keys := []sshtesting.SSHKey{
353+ sshtesting.ValidKeyOne,
354+ sshtesting.ValidKeyTwo,
355+ sshtesting.ValidKeyThree,
356+ }
357+ for _, k := range keys {
358+ fingerprint, _, err := ssh.KeyFingerprint(k.Key)
359+ c.Assert(err, gc.IsNil)
360+ c.Assert(fingerprint, gc.Equals, k.Fingerprint)
361+ }
362+}
363+
364+func (s *FingerprintSuite) TestKeyFingerprintError(c *gc.C) {
365+ _, _, err := ssh.KeyFingerprint("invalid key")
366+ c.Assert(err, gc.ErrorMatches, `generating key fingerprint: invalid keytype "invalid" in line "invalid key"`)
367+}
368
369=== removed file 'utils/ssh/fingerprint_win.go'
370--- utils/ssh/fingerprint_win.go 2013-12-10 01:32:02 +0000
371+++ utils/ssh/fingerprint_win.go 1970-01-01 00:00:00 +0000
372@@ -1,13 +0,0 @@
373-// Copyright 2013 Canonical Ltd.
374-// Licensed under the AGPLv3, see LICENCE file for details.
375-//
376-// +build windows
377-
378-package ssh
379-
380-// keyFingerprint returns the fingerprint and comment for the specified key.
381-func keyFingerprint(key string) (fingerprint, comment string, err error) {
382- // TODO(wallyworld) 2013-12-09 bug # 1259368
383- // Implement this function on Windows.
384- panic("not implemented")
385-}

Subscribers

People subscribed via source and target branches

to status/vote changes: