diff --git a/README.md b/README.md index 9c823eb..7b4d2f6 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,13 @@ 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/") diff --git a/cmd/sish.go b/cmd/sish.go index 6325bc0..8572ad5 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 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") @@ -141,6 +142,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..0034888 --- /dev/null +++ b/utils/authentication_key_request_test.go @@ -0,0 +1,229 @@ +package utils + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "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 +} + +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, 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 + } + 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 { + log.Print("Error parsing authorized public key", err) + continue + } + if bytes.Equal(authorizedKey.Marshal(), marshalled) { + keyMatch = true + break + } + } + for _, username := range *validUsernames { + if reqBody.UserName == username { + usernameMatch = true + } + } + if keyMatch && usernameMatch { + w.WriteHeader(http.StatusOK) + } else { + 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 + 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], + 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], + clientUser: "ubuntu", + validPublicKeys: []rsa.PublicKey{testKeys[0].PublicKey}, + validUsernames: []string{"ubuntu"}, + 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, &c.validUsernames))) + 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(), + User: c.clientUser, + } + 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..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" @@ -15,6 +16,7 @@ import ( "log" mathrand "math/rand" "net" + "net/http" "net/url" "os" "os/signal" @@ -261,6 +263,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 +483,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, c.RemoteAddr(), c.User()) + 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 +510,41 @@ 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, addr net.Addr, user string) (bool, error) { + parsedUrl, err := url.ParseRequestURI(authUrl) + if err != nil { + return false, fmt.Errorf("error parsing url %s", err) + } + + c := &http.Client{ + Timeout: viper.GetDuration("authentication-key-request-timeout"), + } + urlS := parsedUrl.String() + 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 + } + + 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 {