From 446f655ef0b6a9974877be6e3b10e4a629230d46 Mon Sep 17 00:00:00 2001 From: Roshan Jobanputra Date: Wed, 28 Sep 2022 19:01:27 -0400 Subject: [PATCH 1/2] Add authentication-key-request-url option to allow validation of ssh public key auth via an http POST request to a separate application --- README.md | 1 + cmd/sish.go | 2 + utils/authentication_key_request_test.go | 185 +++++++++++++++++++++++ utils/utils.go | 50 ++++++ 4 files changed, 238 insertions(+) create mode 100644 utils/authentication_key_request_test.go diff --git a/README.md b/README.md index bccd60b..310ea69 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ Flags: sish will watch this directory and automatically load new keys and remove keys from the authentication list (default "deploy/pubkeys/") --authentication-keys-directory-watch-interval duration The interval to poll for filesystem changes for SSH keys (default 200ms) + -v, --authentication-key-request-url A url to validate public keys for public key authentication. sish will make an HTTP POST request to this URL with a body containing an OpenSSH 'authorized key' formatted public key. A response with status code 200 indicates approval of the auth key -u, --authentication-password string Password to use for SSH server password authentication --banned-aliases string A comma separated list of banned aliases that users are unable to bind -o, --banned-countries string A comma separated list of banned countries. Applies to HTTP, TCP, and SSH connections diff --git a/cmd/sish.go b/cmd/sish.go index 511a366..4600f36 100644 --- a/cmd/sish.go +++ b/cmd/sish.go @@ -66,6 +66,7 @@ func init() { rootCmd.PersistentFlags().StringP("private-keys-directory", "l", "deploy/keys", "The location of other SSH server private keys. sish will add these as valid auth methods for SSH. Note, these need to be unencrypted OR use the private-key-passphrase") rootCmd.PersistentFlags().StringP("authentication-password", "u", "", "Password to use for SSH server password authentication") rootCmd.PersistentFlags().StringP("authentication-keys-directory", "k", "deploy/pubkeys/", "Directory where public keys for public key authentication are stored.\nsish will watch this directory and automatically load new keys and remove keys\nfrom the authentication list") + rootCmd.PersistentFlags().StringP("authentication-key-request-url", "v", "", "A url to validate public keys for public key authentication.\nsish will make an HTTP POST request to this URL with a body containing an\nOpenSSH 'authorized key' formatted public key.\nA response with status code 200 indicates approval of the auth key") rootCmd.PersistentFlags().StringP("port-bind-range", "n", "0,1024-65535", "Ports or port ranges that sish will allow to be bound when a user attempts to use TCP forwarding") rootCmd.PersistentFlags().StringP("proxy-protocol-version", "q", "1", "What version of the proxy protocol to use. Can either be 1, 2, or userdefined.\nIf userdefined, the user needs to add a command to SSH called proxyproto=version (ie proxyproto=1)") rootCmd.PersistentFlags().StringP("proxy-protocol-policy", "", "use", "What to do with the proxy protocol header. Can be use, ignore, reject, or require") @@ -140,6 +141,7 @@ func init() { rootCmd.PersistentFlags().DurationP("proxy-protocol-timeout", "", 200*time.Millisecond, "The duration to wait for the proxy proto header") rootCmd.PersistentFlags().DurationP("authentication-keys-directory-watch-interval", "", 200*time.Millisecond, "The interval to poll for filesystem changes for SSH keys") rootCmd.PersistentFlags().DurationP("https-certificate-directory-watch-interval", "", 200*time.Millisecond, "The interval to poll for filesystem changes for HTTPS certificates") + rootCmd.PersistentFlags().DurationP("authentication-key-request-timeout", "", 5*time.Second, "Duration to wait for a response from the authentication key request") } // initConfig initializes the configuration and loads needed diff --git a/utils/authentication_key_request_test.go b/utils/authentication_key_request_test.go new file mode 100644 index 0000000..671bc33 --- /dev/null +++ b/utils/authentication_key_request_test.go @@ -0,0 +1,185 @@ +package utils + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "io" + "log" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/spf13/viper" + "golang.org/x/crypto/ssh" +) + +// MakeTestKeys returns a slice of randomly generated private keys. +func MakeTestKeys(numKeys int) []*rsa.PrivateKey { + testKeys := make([]*rsa.PrivateKey, numKeys) + for i := 0; i < numKeys; i++ { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatal(err) + } + testKeys[i] = key + } + return testKeys +} + +// PubKeyHttpHandler returns a http handler function which validates an +// OpenSSH authorized-keys formatted public key against a slice of +// slice authorized keys. +func PubKeyHttpHandler(validPublicKeys *[]rsa.PublicKey) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + pubKey, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKey) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + marshalled := parsedKey.Marshal() + for _, key := range *validPublicKeys { + authorizedKey, err := ssh.NewPublicKey(&key) + if err != nil { + log.Print("Error parsing authorized public key", err) + continue + } + if bytes.Equal(authorizedKey.Marshal(), marshalled) { + w.WriteHeader(http.StatusOK) + return + } + } + w.WriteHeader(http.StatusUnauthorized) + } +} + +// HandleSSHConn accepts an incoming client connection, performs the +// auth handshake to test the GetSSHConfig method using the +// authentication-key-request-url flag. +func HandleSSHConn(sshListener net.Listener, successAuth *chan bool) { + conn, err := sshListener.Accept() + if err != nil { + log.Fatal(err) + } + defer conn.Close() + + // GetSSHConfig is the method we are testing to validate that it + // can use an http request to validate client public key auth + connection, _, _, err := ssh.NewServerConn(conn, GetSSHConfig()) + + if err != nil { + *successAuth <- false + return + } + connection.Close() + + *successAuth <- true +} + +// TestAuthenticationKeyRequest validates that the utils.GetSSHConfig +// PublicKey auth works with the authentication-key-request-url parameter. +func TestAuthenticationKeyRequest(t *testing.T) { + testKeys := MakeTestKeys(3) + + // Give sish a temp directory to generate a server ssh host key + dir, err := os.MkdirTemp("", "sish_keys") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(dir) + viper.Set("private-keys-directory", dir) + viper.Set("authentication", true) + + testCases := []struct { + clientPrivateKey *rsa.PrivateKey + validPublicKeys []rsa.PublicKey + expectSuccessAuth bool + overrideHttpUrl string + }{ + // valid key, should succeed auth + { + clientPrivateKey: testKeys[0], + validPublicKeys: []rsa.PublicKey{testKeys[0].PublicKey}, + expectSuccessAuth: true, + overrideHttpUrl: "", + }, + // invalid key, should be rejected + { + clientPrivateKey: testKeys[0], + validPublicKeys: []rsa.PublicKey{testKeys[1].PublicKey, testKeys[2].PublicKey}, + expectSuccessAuth: false, + overrideHttpUrl: "", + }, + // no http service listening on server url, should be rejected + { + clientPrivateKey: testKeys[0], + validPublicKeys: []rsa.PublicKey{}, + expectSuccessAuth: false, + overrideHttpUrl: "http://localhost:61234", + }, + // invalid http url, should be rejected + { + clientPrivateKey: testKeys[0], + validPublicKeys: []rsa.PublicKey{}, + expectSuccessAuth: false, + overrideHttpUrl: "notarealurl", + }, + } + + for caseIdx, c := range testCases { + if c.overrideHttpUrl == "" { + // start an http server that will validate against the specified public keys + httpSrv := httptest.NewServer(http.HandlerFunc(PubKeyHttpHandler(&c.validPublicKeys))) + defer httpSrv.Close() + + // set viper to this http server URL as the auth request url it will + // send public keys to for auth validation + viper.Set("authentication-key-request-url", httpSrv.URL) + } else { + viper.Set("authentication-key-request-url", c.overrideHttpUrl) + } + + sshListener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Error(err) + } + defer sshListener.Close() + + successAuth := make(chan bool) + go HandleSSHConn(sshListener, &successAuth) + + // // attempt to connect to the ssh server using the specified private key + signer, err := ssh.NewSignerFromKey(c.clientPrivateKey) + if err != nil { + t.Error(err) + } + clientConfig := &ssh.ClientConfig{ + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + t.Log(clientConfig) + + client, err := ssh.Dial("tcp", sshListener.Addr().String(), clientConfig) + if err != nil { + t.Log("ssh client rejected", err) + } else { + t.Log("ssh client connected") + client.Close() + } + + didAuth := <-successAuth + + if didAuth != c.expectSuccessAuth { + t.Errorf("Auth %t when should have been %t for case %d", didAuth, c.expectSuccessAuth, caseIdx) + } + } +} diff --git a/utils/utils.go b/utils/utils.go index e451def..a243b5b 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -15,6 +15,7 @@ import ( "log" mathrand "math/rand" "net" + "net/http" "net/url" "os" "os/signal" @@ -261,6 +262,11 @@ func loadPrivateKeys(config *ssh.ServerConfig) { } err := filepath.WalkDir(viper.GetString("private-keys-directory"), func(path string, d fs.DirEntry, err error) error { + if err != nil && d == nil { + // This is likely an error with the directory we are walking (such as it not existing) + return err + } + if d.IsDir() { return nil } @@ -476,6 +482,24 @@ func GetSSHConfig() *ssh.ServerConfig { } } + // Allow validation of public keys via a sub-request to another service + authUrl := viper.GetString("authentication-key-request-url") + if authUrl != "" { + validKey, err := checkAuthenticationKeyRequest(authUrl, authKey) + if err != nil { + log.Printf("Error calling authentication URL %s: %s\n", authUrl, err) + } + if validKey { + permssionsData := &ssh.Permissions{ + Extensions: map[string]string{ + "pubKey": string(authKey), + "pubKeyFingerprint": ssh.FingerprintSHA256(key), + }, + } + return permssionsData, nil + } + } + return nil, fmt.Errorf("public key doesn't match") }, } @@ -485,6 +509,32 @@ func GetSSHConfig() *ssh.ServerConfig { return sshConfig } +// checkAuthenticationKeyRequest makes an HTTP POST request to the specified url with +// the provided ssh public key in OpenSSH 'authorized keys' format to validate +// whether it should be accepted. +func checkAuthenticationKeyRequest(authUrl string, authKey []byte) (bool, error) { + parsedUrl, err := url.ParseRequestURI(authUrl) + if err != nil || !parsedUrl.IsAbs() { + return false, fmt.Errorf("error parsing url %s", err) + } + + c := &http.Client{ + Timeout: viper.GetDuration("authentication-key-request-timeout"), + } + urlS := parsedUrl.String() + res, err := c.Post(urlS, "text/plain", bytes.NewBuffer(authKey)) + if err != nil { + return false, err + } + + if res.StatusCode != http.StatusOK { + log.Printf("Public key rejected by auth service: %s with status %d", urlS, res.StatusCode) + return false, nil + } + + return true, nil +} + // generatePrivateKey creates a new ed25519 private key to be used by the // the SSH server as the host key. func generatePrivateKey(passphrase string) []byte { From bff79c79f2638b5784b4025ff2ff68577c31fc06 Mon Sep 17 00:00:00 2001 From: Roshan Jobanputra Date: Mon, 17 Oct 2022 19:12:53 -0400 Subject: [PATCH 2/2] Switch to using JSON body in request and include username & remote address of client. --- README.md | 8 ++- cmd/sish.go | 2 +- utils/authentication_key_request_test.go | 62 ++++++++++++++++++++---- utils/utils.go | 18 +++++-- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 310ea69..6a066d0 100644 --- a/README.md +++ b/README.md @@ -316,11 +316,17 @@ Flags: --append-user-to-subdomain Append the SSH user to the subdomain. This is useful in multitenant environments --append-user-to-subdomain-separator string The token to use for separating username and subdomain selection in a virtualhost (default "-") --authentication Require authentication for the SSH service (default true) + --authentication-key-request-timeout duration Duration to wait for a response from the authentication key request (default 5s) + -v, --authentication-key-request-url string A url to validate public keys for public key authentication. + sish will make an HTTP POST request to this URL with a JSON body containing an + OpenSSH 'authorized key' formatted public key, username, + and ip address. E.g.: + {"auth_key": string, "user": string, "remote_addr": string} + A response with status code 200 indicates approval of the auth key -k, --authentication-keys-directory string Directory where public keys for public key authentication are stored. sish will watch this directory and automatically load new keys and remove keys from the authentication list (default "deploy/pubkeys/") --authentication-keys-directory-watch-interval duration The interval to poll for filesystem changes for SSH keys (default 200ms) - -v, --authentication-key-request-url A url to validate public keys for public key authentication. sish will make an HTTP POST request to this URL with a body containing an OpenSSH 'authorized key' formatted public key. A response with status code 200 indicates approval of the auth key -u, --authentication-password string Password to use for SSH server password authentication --banned-aliases string A comma separated list of banned aliases that users are unable to bind -o, --banned-countries string A comma separated list of banned countries. Applies to HTTP, TCP, and SSH connections diff --git a/cmd/sish.go b/cmd/sish.go index 4600f36..6b50845 100644 --- a/cmd/sish.go +++ b/cmd/sish.go @@ -66,7 +66,7 @@ func init() { rootCmd.PersistentFlags().StringP("private-keys-directory", "l", "deploy/keys", "The location of other SSH server private keys. sish will add these as valid auth methods for SSH. Note, these need to be unencrypted OR use the private-key-passphrase") rootCmd.PersistentFlags().StringP("authentication-password", "u", "", "Password to use for SSH server password authentication") rootCmd.PersistentFlags().StringP("authentication-keys-directory", "k", "deploy/pubkeys/", "Directory where public keys for public key authentication are stored.\nsish will watch this directory and automatically load new keys and remove keys\nfrom the authentication list") - rootCmd.PersistentFlags().StringP("authentication-key-request-url", "v", "", "A url to validate public keys for public key authentication.\nsish will make an HTTP POST request to this URL with a body containing an\nOpenSSH 'authorized key' formatted public key.\nA response with status code 200 indicates approval of the auth key") + rootCmd.PersistentFlags().StringP("authentication-key-request-url", "v", "", "A url to validate public keys for public key authentication.\nsish will make an HTTP POST request to this URL with a JSON body containing an\nOpenSSH 'authorized key' formatted public key, username,\nand ip address. E.g.:\n{\"auth_key\": string, \"user\": string, \"remote_addr\": string}\nA response with status code 200 indicates approval of the auth key") rootCmd.PersistentFlags().StringP("port-bind-range", "n", "0,1024-65535", "Ports or port ranges that sish will allow to be bound when a user attempts to use TCP forwarding") rootCmd.PersistentFlags().StringP("proxy-protocol-version", "q", "1", "What version of the proxy protocol to use. Can either be 1, 2, or userdefined.\nIf userdefined, the user needs to add a command to SSH called proxyproto=version (ie proxyproto=1)") rootCmd.PersistentFlags().StringP("proxy-protocol-policy", "", "use", "What to do with the proxy protocol header. Can be use, ignore, reject, or require") diff --git a/utils/authentication_key_request_test.go b/utils/authentication_key_request_test.go index 671bc33..0034888 100644 --- a/utils/authentication_key_request_test.go +++ b/utils/authentication_key_request_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/rand" "crypto/rsa" + "encoding/json" "io" "log" "net" @@ -29,22 +30,36 @@ func MakeTestKeys(numKeys int) []*rsa.PrivateKey { return testKeys } +type AuthRequestBody struct { + PubKey string `json:"auth_key"` + UserName string `json:"user"` + RemoteAddr string `json:"remote_addr"` +} + // PubKeyHttpHandler returns a http handler function which validates an // OpenSSH authorized-keys formatted public key against a slice of // slice authorized keys. -func PubKeyHttpHandler(validPublicKeys *[]rsa.PublicKey) func(w http.ResponseWriter, r *http.Request) { +func PubKeyHttpHandler(validPublicKeys *[]rsa.PublicKey, validUsernames *[]string) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { pubKey, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKey) + var reqBody AuthRequestBody + err = json.Unmarshal(pubKey, &reqBody) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(reqBody.PubKey)) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } marshalled := parsedKey.Marshal() + keyMatch := false + usernameMatch := false for _, key := range *validPublicKeys { authorizedKey, err := ssh.NewPublicKey(&key) if err != nil { @@ -52,11 +67,20 @@ func PubKeyHttpHandler(validPublicKeys *[]rsa.PublicKey) func(w http.ResponseWri continue } if bytes.Equal(authorizedKey.Marshal(), marshalled) { - w.WriteHeader(http.StatusOK) - return + keyMatch = true + break + } + } + for _, username := range *validUsernames { + if reqBody.UserName == username { + usernameMatch = true } } - w.WriteHeader(http.StatusUnauthorized) + if keyMatch && usernameMatch { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusUnauthorized) + } } } @@ -99,35 +123,54 @@ func TestAuthenticationKeyRequest(t *testing.T) { testCases := []struct { clientPrivateKey *rsa.PrivateKey + clientUser string validPublicKeys []rsa.PublicKey + validUsernames []string expectSuccessAuth bool overrideHttpUrl string }{ // valid key, should succeed auth { clientPrivateKey: testKeys[0], + clientUser: "ubuntu", validPublicKeys: []rsa.PublicKey{testKeys[0].PublicKey}, + validUsernames: []string{"ubuntu"}, expectSuccessAuth: true, overrideHttpUrl: "", }, // invalid key, should be rejected { clientPrivateKey: testKeys[0], + clientUser: "ubuntu", validPublicKeys: []rsa.PublicKey{testKeys[1].PublicKey, testKeys[2].PublicKey}, + validUsernames: []string{"ubuntu"}, + expectSuccessAuth: false, + overrideHttpUrl: "", + }, + // invalid username, should be rejected + { + clientPrivateKey: testKeys[0], + clientUser: "windows", + validPublicKeys: []rsa.PublicKey{testKeys[0].PublicKey}, + validUsernames: []string{"ubuntu"}, expectSuccessAuth: false, overrideHttpUrl: "", }, // no http service listening on server url, should be rejected { clientPrivateKey: testKeys[0], - validPublicKeys: []rsa.PublicKey{}, + clientUser: "ubuntu", + validPublicKeys: []rsa.PublicKey{testKeys[0].PublicKey}, + validUsernames: []string{"ubuntu"}, expectSuccessAuth: false, overrideHttpUrl: "http://localhost:61234", }, // invalid http url, should be rejected { clientPrivateKey: testKeys[0], - validPublicKeys: []rsa.PublicKey{}, + clientUser: "ubuntu", + validPublicKeys: []rsa.PublicKey{testKeys[0].PublicKey}, + validUsernames: []string{"ubuntu"}, expectSuccessAuth: false, overrideHttpUrl: "notarealurl", }, @@ -136,7 +179,7 @@ func TestAuthenticationKeyRequest(t *testing.T) { for caseIdx, c := range testCases { if c.overrideHttpUrl == "" { // start an http server that will validate against the specified public keys - httpSrv := httptest.NewServer(http.HandlerFunc(PubKeyHttpHandler(&c.validPublicKeys))) + httpSrv := httptest.NewServer(http.HandlerFunc(PubKeyHttpHandler(&c.validPublicKeys, &c.validUsernames))) defer httpSrv.Close() // set viper to this http server URL as the auth request url it will @@ -155,7 +198,7 @@ func TestAuthenticationKeyRequest(t *testing.T) { successAuth := make(chan bool) go HandleSSHConn(sshListener, &successAuth) - // // attempt to connect to the ssh server using the specified private key + // attempt to connect to the ssh server using the specified private key signer, err := ssh.NewSignerFromKey(c.clientPrivateKey) if err != nil { t.Error(err) @@ -165,6 +208,7 @@ func TestAuthenticationKeyRequest(t *testing.T) { ssh.PublicKeys(signer), }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), + User: c.clientUser, } t.Log(clientConfig) diff --git a/utils/utils.go b/utils/utils.go index a243b5b..5fa8ff6 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -8,6 +8,7 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "encoding/json" "encoding/pem" "fmt" "io" @@ -485,7 +486,7 @@ func GetSSHConfig() *ssh.ServerConfig { // Allow validation of public keys via a sub-request to another service authUrl := viper.GetString("authentication-key-request-url") if authUrl != "" { - validKey, err := checkAuthenticationKeyRequest(authUrl, authKey) + validKey, err := checkAuthenticationKeyRequest(authUrl, authKey, c.RemoteAddr(), c.User()) if err != nil { log.Printf("Error calling authentication URL %s: %s\n", authUrl, err) } @@ -512,9 +513,9 @@ func GetSSHConfig() *ssh.ServerConfig { // checkAuthenticationKeyRequest makes an HTTP POST request to the specified url with // the provided ssh public key in OpenSSH 'authorized keys' format to validate // whether it should be accepted. -func checkAuthenticationKeyRequest(authUrl string, authKey []byte) (bool, error) { +func checkAuthenticationKeyRequest(authUrl string, authKey []byte, addr net.Addr, user string) (bool, error) { parsedUrl, err := url.ParseRequestURI(authUrl) - if err != nil || !parsedUrl.IsAbs() { + if err != nil { return false, fmt.Errorf("error parsing url %s", err) } @@ -522,7 +523,16 @@ func checkAuthenticationKeyRequest(authUrl string, authKey []byte) (bool, error) Timeout: viper.GetDuration("authentication-key-request-timeout"), } urlS := parsedUrl.String() - res, err := c.Post(urlS, "text/plain", bytes.NewBuffer(authKey)) + reqBodyMap := map[string]string{ + "auth_key": string(authKey), + "remote_addr": addr.String(), + "user": user, + } + reqBody, err := json.Marshal(reqBodyMap) + if err != nil { + return false, fmt.Errorf("error jsonifying request body") + } + res, err := c.Post(urlS, "application/json", bytes.NewBuffer(reqBody)) if err != nil { return false, err }