Skip to content

Commit

Permalink
Add authentication-key-request-url option (#247)
Browse files Browse the repository at this point in the history
* Add authentication-key-request-url option to allow validation of ssh public key auth via an http POST request to a separate application

* Switch to using JSON body in request and include username & remote address of client.
  • Loading branch information
rjobanp committed Oct 21, 2022
1 parent bcd6911 commit fe2b1c2
Show file tree
Hide file tree
Showing 4 changed files with 298 additions and 0 deletions.
7 changes: 7 additions & 0 deletions README.md
Expand Up @@ -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/")
Expand Down
2 changes: 2 additions & 0 deletions cmd/sish.go
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
229 changes: 229 additions & 0 deletions 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)
}
}
}
60 changes: 60 additions & 0 deletions utils/utils.go
Expand Up @@ -8,13 +8,15 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"io/fs"
"log"
mathrand "math/rand"
"net"
"net/http"
"net/url"
"os"
"os/signal"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
},
}
Expand All @@ -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 {
Expand Down

0 comments on commit fe2b1c2

Please sign in to comment.