diff --git a/ssh/keys.go b/ssh/keys.go index 1f37864..7225ecf 100644 --- a/ssh/keys.go +++ b/ssh/keys.go @@ -5,11 +5,22 @@ package ssh import ( + "bytes" "crypto/dsa" "crypto/rsa" + "encoding/base64" "math/big" ) +// Key types supported by OpenSSH 5.9 +const ( + keyAlgoRSA = "ssh-rsa" + keyAlgoDSA = "ssh-dss" + keyAlgoECDSA256 = "ecdsa-sha2-nistp256" + keyAlgoECDSA384 = "ecdsa-sha2-nistp384" + keyAlgoECDSA521 = "ecdsa-sha2-nistp521" +) + // parsePubKey parses a public key according to RFC 4253, section 6.6. func parsePubKey(in []byte) (out interface{}, rest []byte, ok bool) { algo, in, ok := parseString(in) @@ -118,3 +129,175 @@ func marshalPubDSA(key *dsa.PublicKey) []byte { return ret } + +// parseAuthorizedKey parses a public key in OpenSSH authorized_keys format +// (see sshd(8) manual page) once the options and key type fields have been +// removed. +func parseAuthorizedKey(in []byte) (out interface{}, comment string, ok bool) { + in = bytes.TrimSpace(in) + + i := bytes.IndexAny(in, " \t") + if i == -1 { + i = len(in) + } + base64Key := in[:i] + + key := make([]byte, base64.StdEncoding.DecodedLen(len(base64Key))) + n, err := base64.StdEncoding.Decode(key, base64Key) + if err != nil { + return + } + key = key[:n] + out, _, ok = parsePubKey(key) + if !ok { + return nil, "", false + } + comment = string(bytes.TrimSpace(in[i:])) + return +} + +// ParseAuthorizedKeys parses a public key from an authorized_keys +// file used in OpenSSH according to the sshd(8) manual page. +func ParseAuthorizedKey(in []byte) (out interface{}, comment string, options []string, rest []byte, ok bool) { + for len(in) > 0 { + end := bytes.IndexByte(in, '\n') + if end != -1 { + rest = in[end+1:] + in = in[:end] + } else { + rest = nil + } + + end = bytes.IndexByte(in, '\r') + if end != -1 { + in = in[:end] + } + + in = bytes.TrimSpace(in) + if len(in) == 0 || in[0] == '#' { + in = rest + continue + } + + i := bytes.IndexAny(in, " \t") + if i == -1 { + in = rest + continue + } + + field := string(in[:i]) + switch field { + case keyAlgoRSA, keyAlgoDSA: + out, comment, ok = parseAuthorizedKey(in[i:]) + if ok { + return + } + case keyAlgoECDSA256, keyAlgoECDSA384, keyAlgoECDSA521: + // We don't support these keys. + in = rest + continue + case hostAlgoRSACertV01, hostAlgoDSACertV01, + hostAlgoECDSA256CertV01, hostAlgoECDSA384CertV01, hostAlgoECDSA521CertV01: + // We don't support these certificates. + in = rest + continue + } + + // No key type recognised. Maybe there's an options field at + // the beginning. + var b byte + inQuote := false + var candidateOptions []string + optionStart := 0 + for i, b = range in { + isEnd := !inQuote && (b == ' ' || b == '\t') + if (b == ',' && !inQuote) || isEnd { + if i-optionStart > 0 { + candidateOptions = append(candidateOptions, string(in[optionStart:i])) + } + optionStart = i + 1 + } + if isEnd { + break + } + if b == '"' && (i == 0 || (i > 0 && in[i-1] != '\\')) { + inQuote = !inQuote + } + } + for i < len(in) && (in[i] == ' ' || in[i] == '\t') { + i++ + } + if i == len(in) { + // Invalid line: unmatched quote + in = rest + continue + } + + in = in[i:] + i = bytes.IndexAny(in, " \t") + if i == -1 { + in = rest + continue + } + + field = string(in[:i]) + switch field { + case keyAlgoRSA, keyAlgoDSA: + out, comment, ok = parseAuthorizedKey(in[i:]) + if ok { + options = candidateOptions + return + } + } + + in = rest + continue + } + + return +} + +// ParsePublicKey parses an SSH public key formatted for use in +// the SSH wire protocol. +func ParsePublicKey(in []byte) (out interface{}, rest []byte, ok bool) { + return parsePubKey(in) +} + +// MarshalAuthorizedKey returns a byte stream suitable for inclusion +// in an OpenSSH authorized_keys file following the format specified +// in the sshd(8) manual page. +func MarshalAuthorizedKey(key interface{}) []byte { + b := &bytes.Buffer{} + switch keyType := key.(type) { + case *rsa.PublicKey: + b.WriteString(hostAlgoRSA) + case *dsa.PublicKey: + b.WriteString(hostAlgoDSA) + case *OpenSSHCertV01: + switch keyType.Key.(type) { + case *rsa.PublicKey: + b.WriteString(hostAlgoRSACertV01) + case *dsa.PublicKey: + b.WriteString(hostAlgoDSACertV01) + default: + panic("unexpected key type") + } + default: + panic("unexpected key type") + } + + b.WriteByte(' ') + e := base64.NewEncoder(base64.StdEncoding, b) + e.Write(serializePublickey(key)) + e.Close() + b.WriteByte('\n') + return b.Bytes() +} + +// MarshalPublicKey serializes a *rsa.PublicKey, *dsa.PublicKey or +// *OpenSSHCertV01 for use in the SSH wire protocol. It can be used for +// comparison with the pubkey argument of ServerConfig's PublicKeyCallback as +// well as for generating an authorized_keys or host_keys file. +func MarshalPublicKey(key interface{}) []byte { + return serializePublickey(key) +} diff --git a/ssh/test/keys_test.go b/ssh/test/keys_test.go new file mode 100644 index 0000000..c00162e --- /dev/null +++ b/ssh/test/keys_test.go @@ -0,0 +1,176 @@ +package test + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "reflect" + "strings" + "testing" + + "code.google.com/p/go.crypto/ssh" +) + +var ( + validKey = `AAAAB3NzaC1yc2EAAAADAQABAAABAQDEX/dPu4PmtvgK3La9zioCEDrJ` + + `yUr6xEIK7Pr+rLgydcqWTU/kt7w7gKjOw4vvzgHfjKl09CWyvgb+y5dCiTk` + + `9MxI+erGNhs3pwaoS+EavAbawB7iEqYyTep3YaJK+4RJ4OX7ZlXMAIMrTL+` + + `UVrK89t56hCkFYaAgo3VY+z6rb/b3bDBYtE1Y2tS7C3au73aDgeb9psIrSV` + + `86ucKBTl5X62FnYiyGd++xCnLB6uLximM5OKXfLzJQNS/QyZyk12g3D8y69` + + `Xw1GzCSKX1u1+MQboyf0HJcG2ryUCLHdcDVppApyHx2OLq53hlkQ/yxdflD` + + `qCqAE4j+doagSsIfC1T2T` + + authWithOptions = []string{ + `# comments to ignore before any keys...`, + ``, + `env="HOME=/home/root",no-port-forwarding ssh-rsa ` + validKey + ` user@host`, + `# comments to ignore, along with a blank line`, + ``, + `env="HOME=/home/root2" ssh-rsa ` + validKey + ` user2@host2`, + ``, + `# more comments, plus a invalid entry`, + `ssh-rsa data-that-will-not-parse user@host3`, + } + + authOptions = strings.Join(authWithOptions, "\n") + authWithCRLF = strings.Join(authWithOptions, "\r\n") + authInvalid = []byte(`ssh-rsa`) + authWithQuotedCommaInEnv = []byte(`env="HOME=/home/root,dir",no-port-forwarding ssh-rsa ` + validKey + ` user@host`) + authWithQuotedSpaceInEnv = []byte(`env="HOME=/home/root dir",no-port-forwarding ssh-rsa ` + validKey + ` user@host`) + authWithQuotedQuoteInEnv = []byte(`env="HOME=/home/\"root dir",no-port-forwarding` + "\t" + `ssh-rsa` + "\t" + validKey + ` user@host`) + + authWithDoubleQuotedQuote = []byte(`no-port-forwarding,env="HOME=/home/ \"root dir\"" ssh-rsa ` + validKey + "\t" + `user@host`) + authWithInvalidSpace = []byte(`env="HOME=/home/root dir", no-port-forwarding ssh-rsa ` + validKey + ` user@host +#more to follow but still no valid keys`) + authWithMissingQuote = []byte(`env="HOME=/home/root,no-port-forwarding ssh-rsa ` + validKey + ` user@host +env="HOME=/home/root",shared-control ssh-rsa ` + validKey + ` user@host`) +) + +func TestMarshalParsePublicKey(t *testing.T) { + pub := getTestPublicKey(t) + authKeys := ssh.MarshalAuthorizedKey(pub) + actualFields := strings.Fields(string(authKeys)) + if len(actualFields) == 0 { + t.Fatalf("failed authKeys: %v", authKeys) + } + + // drop the comment + expectedFields := strings.Fields(keys["authorized_keys"])[0:2] + + if !reflect.DeepEqual(actualFields, expectedFields) { + t.Errorf("got %v, expected %v", actualFields, expectedFields) + } + + actPub, _, _, _, ok := ssh.ParseAuthorizedKey([]byte(keys["authorized_keys"])) + if !ok { + t.Fatalf("cannot parse %v", keys["authorized_keys"]) + } + if !reflect.DeepEqual(actPub, pub) { + t.Errorf("got %v, expected %v", actPub, pub) + } +} + +type authResult struct { + pubKey interface{} //*rsa.PublicKey + options []string + comments string + rest string + ok bool +} + +func testAuthorizedKeys(t *testing.T, authKeys []byte, expected []authResult) { + rest := authKeys + var values []authResult + for len(rest) > 0 { + var r authResult + r.pubKey, r.comments, r.options, rest, r.ok = ssh.ParseAuthorizedKey(rest) + r.rest = string(rest) + values = append(values, r) + } + + if !reflect.DeepEqual(values, expected) { + t.Errorf("got %q, expected %q", values, expected) + } + +} + +func getTestPublicKey(t *testing.T) *rsa.PublicKey { + block, _ := pem.Decode([]byte(testClientPrivateKey)) + if block == nil { + t.Fatalf("pem.Decode: %v", testClientPrivateKey) + } + priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + t.Fatalf("x509.ParsePKCS1PrivateKey: %v", err) + } + + return &priv.PublicKey +} + +func TestAuth(t *testing.T) { + pub := getTestPublicKey(t) + rest2 := strings.Join(authWithOptions[3:], "\n") + rest3 := strings.Join(authWithOptions[6:], "\n") + testAuthorizedKeys(t, []byte(authOptions), []authResult{ + {pub, []string{`env="HOME=/home/root"`, "no-port-forwarding"}, "user@host", rest2, true}, + {pub, []string{`env="HOME=/home/root2"`}, "user2@host2", rest3, true}, + {nil, nil, "", "", false}, + }) +} + +func TestAuthWithCRLF(t *testing.T) { + pub := getTestPublicKey(t) + rest2 := strings.Join(authWithOptions[3:], "\r\n") + rest3 := strings.Join(authWithOptions[6:], "\r\n") + testAuthorizedKeys(t, []byte(authWithCRLF), []authResult{ + {pub, []string{`env="HOME=/home/root"`, "no-port-forwarding"}, "user@host", rest2, true}, + {pub, []string{`env="HOME=/home/root2"`}, "user2@host2", rest3, true}, + {nil, nil, "", "", false}, + }) +} + +func TestAuthWithQuotedSpaceInEnv(t *testing.T) { + pub := getTestPublicKey(t) + testAuthorizedKeys(t, []byte(authWithQuotedSpaceInEnv), []authResult{ + {pub, []string{`env="HOME=/home/root dir"`, "no-port-forwarding"}, "user@host", "", true}, + }) +} + +func TestAuthWithQuotedCommaInEnv(t *testing.T) { + pub := getTestPublicKey(t) + testAuthorizedKeys(t, []byte(authWithQuotedCommaInEnv), []authResult{ + {pub, []string{`env="HOME=/home/root,dir"`, "no-port-forwarding"}, "user@host", "", true}, + }) +} + +func TestAuthWithQuotedQuoteInEnv(t *testing.T) { + pub := getTestPublicKey(t) + testAuthorizedKeys(t, []byte(authWithQuotedQuoteInEnv), []authResult{ + {pub, []string{`env="HOME=/home/\"root dir"`, "no-port-forwarding"}, "user@host", "", true}, + }) + + testAuthorizedKeys(t, []byte(authWithDoubleQuotedQuote), []authResult{ + {pub, []string{"no-port-forwarding", `env="HOME=/home/ \"root dir\""`}, "user@host", "", true}, + }) + +} + +func TestAuthWithInvalidSpace(t *testing.T) { + testAuthorizedKeys(t, []byte(authWithInvalidSpace), []authResult{ + {nil, nil, "", "", false}, + }) +} + +func TestAuthWithMissingQuote(t *testing.T) { + pub := getTestPublicKey(t) + testAuthorizedKeys(t, []byte(authWithMissingQuote), []authResult{ + {pub, []string{`env="HOME=/home/root"`, `shared-control`}, "user@host", "", true}, + }) +} + +func TestInvalidEntry(t *testing.T) { + _, _, _, _, ok := ssh.ParseAuthorizedKey(authInvalid) + if ok { + t.Errorf("Expected invalid entry, returned valid entry") + } +}