diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..3d8f98ec --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +on: [push, pull_request] +name: Test +jobs: + test: + strategy: + matrix: + go-version: [1.20.x] + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + - uses: actions/checkout@v3 + - run: make test + diff --git a/Makefile b/Makefile index 314b930e..054b34b6 100644 --- a/Makefile +++ b/Makefile @@ -10,18 +10,29 @@ endif BINARY=keymaster # These are the values we want to pass for Version and BuildTime -VERSION=1.9.0 +VERSION=1.14.0 #BUILD_TIME=`date +%FT%T%z` # Setup the -ldflags option for go build here, interpolate the variable values #LDFLAGS=-ldflags "-X github.com/ariejan/roll/core.Version=${VERSION} -X github.com/ariejan/roll/core.BuildTime=${BUILD_TIME}" -all: init-config-host - cd $(GOPATH)/src; go install -ldflags "-X main.Version=${VERSION}" github.com/Cloud-Foundations/keymaster/cmd/* +all: init-config-host cmd/keymasterd/binData.go + cd cmd/keymaster; go install -ldflags "-X main.Version=${VERSION}" + cd cmd/keymasterd; go install -ldflags "-X main.Version=${VERSION}" + cd cmd/keymaster-unlocker; go install -ldflags "-X main.Version=${VERSION}" + cd cmd/keymaster-eventmond; go install -ldflags "-X main.Version=${VERSION}" -win-client: - cd $(GOPATH)\src && go install -ldflags "-X main.Version=${VERSION}" github.com\Cloud-Foundations\keymaster\cmd\keymaster - cd $(GOPATH)\src\github.com\Cloud-Foundations\keymaster\cmd\keymaster && go test -v ./... +build: cmd/keymasterd/binData.go + go build -ldflags "-X main.Version=${VERSION}" -o bin/ ./... + +cmd/keymasterd/binData.go: + -go-bindata -fs -o cmd/keymasterd/binData.go -prefix cmd/keymasterd/data cmd/keymasterd/data/... + +win-client: client-test + go build -ldflags "-X main.Version=${VERSION}" -o bin .\cmd\keymaster\ + +client-test: + go test -v ./cmd/keymaster/... get-deps: init-config-host go get -t ./... @@ -38,7 +49,10 @@ ${BINARY}-${VERSION}.tar.gz: rsync -av --exclude="config.yml" --exclude="*.pem" --exclude="*.out" lib/ ${BINARY}-${VERSION}/lib/ rsync -av --exclude="config.yml" --exclude="*.pem" --exclude="*.out" --exclude="*.key" cmd/ ${BINARY}-${VERSION}/cmd/ rsync -av misc/ ${BINARY}-${VERSION}/misc/ - cp LICENSE Makefile keymaster.spec README.md ${BINARY}-${VERSION}/ + rsync -av proto/ ${BINARY}-${VERSION}/proto/ + rsync -av keymasterd/ ${BINARY}-${VERSION}/keymasterd/ + rsync -av eventmon/ ${BINARY}-${VERSION}/eventmon/ + cp -p LICENSE Makefile keymaster.spec README.md go.mod go.sum ${BINARY}-${VERSION}/ tar -cvzf ${BINARY}-${VERSION}.tar.gz ${BINARY}-${VERSION}/ rm -rf ${BINARY}-${VERSION}/ diff --git a/README.md b/README.md index fb8ac83e..437aeff6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Keymaster -[![Build Status](https://travis-ci.org/Cloud-Foundations/keymaster.svg?branch=master)](https://travis-ci.org/Cloud-Foundations/keymaster) + +[![Build Status](https://github.com/Cloud-Foundations/keymaster/actions/workflows/test.yml/badge.svg?query=branch%3Amaster)](https://github.com/Cloud-Foundations/keymaster/actions/workflows/test.yml?query=branch%3Amaster) [![Coverage Status](https://coveralls.io/repos/github/Cloud-Foundations/keymaster/badge.svg?branch=master)](https://coveralls.io/github/Cloud-Foundations/keymaster?branch=master) -[![Go Report Card](https://goreportcard.com/badge/github.com/Cloud-Foundations/keymaster)](https://goreportcard.com/report/github.com/Cloud-Foundations/keymaster) Keymaster is usable short-term certificate based identity system. With a primary goal to be a single-sign-on (with optional second factor with [Symantec VIP](https://vip.symantec.com/), [U2F](https://fidoalliance.org/specifications/overview/) tokens or [TOTP](https://en.wikipedia.org/wiki/Time-based_One-time_Password_algorithm) compatible apps ([FreeOTP](https://freeotp.github.io/)/google authenticator ) ) for CLI operations (both SSHD and TLS). @@ -35,7 +35,7 @@ Pre-build binaries (both RPM and DEB) can be found here: [releases page](https:/ * make * gcc -For Windows (both gcc and gnu-make) use: [TDM-GCC (64 bit)](https://sourceforge.net/projects/tdm-gcc/). +For Windows (both gcc and gnu-make) use: [TDM-GCC (64 bit)](https://sourceforge.net/projects/tdm-gcc/). Recent windows builds fail when using TDM-GCC 5.x. Successful builds are known with golang 1.16.X and gcc 10.X. #### Building 1. make get-deps @@ -98,7 +98,7 @@ patents and contracts. ## LICENSE Copyright 2016-2019 Symantec Corporation. -Copyright 2019-2020 Cloud-Foundations.org +Copyright 2019-2021 Cloud-Foundations.org Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. @@ -110,3 +110,7 @@ License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +## Versioning +Keymaster versions follow the [Sementic Versioning](https://semver.org/) +guidelines. diff --git a/cmd/keymaster-unlocker/main.go b/cmd/keymaster-unlocker/main.go index 7aec87ae..8f5a9e7b 100644 --- a/cmd/keymaster-unlocker/main.go +++ b/cmd/keymaster-unlocker/main.go @@ -69,7 +69,8 @@ func main() { logger.Fatal(err) } // Setup HTTPS clients. - tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} + tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12} tlsConfig.BuildNameToCertificate() clients := makeClients(addrs, tlsConfig) var password string diff --git a/cmd/keymaster/main.go b/cmd/keymaster/main.go index cd8a172f..0ab5a1e4 100644 --- a/cmd/keymaster/main.go +++ b/cmd/keymaster/main.go @@ -1,9 +1,6 @@ package main import ( - "crypto/ed25519" - "crypto/rand" - "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/pem" @@ -20,20 +17,27 @@ import ( "strings" "time" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "github.com/Cloud-Foundations/Dominator/lib/log/cmdlogger" "github.com/Cloud-Foundations/Dominator/lib/net/rrdialer" "github.com/Cloud-Foundations/golib/pkg/log" + "github.com/Cloud-Foundations/keymaster/lib/client/aws_role" "github.com/Cloud-Foundations/keymaster/lib/client/config" libnet "github.com/Cloud-Foundations/keymaster/lib/client/net" "github.com/Cloud-Foundations/keymaster/lib/client/sshagent" "github.com/Cloud-Foundations/keymaster/lib/client/twofa" "github.com/Cloud-Foundations/keymaster/lib/client/twofa/u2f" "github.com/Cloud-Foundations/keymaster/lib/client/util" + "github.com/Cloud-Foundations/keymaster/lib/client/webauth" ) -const DefaultSSHKeysLocation = "/.ssh/" -const DefaultTLSKeysLocation = "/.ssl/" -const DefaultTMPKeysLocation = "/.keymaster/" +const ( + DefaultSSHKeysLocation = "/.ssh/" + DefaultTLSKeysLocation = "/.ssl/" + keymasterSubdir = ".keymaster" +) const userAgentAppName = "keymaster" const defaultVersionNumber = "No version provided" @@ -47,14 +51,22 @@ var ( ) var ( - configFilename = flag.String("config", filepath.Join(getUserHomeDir(), ".keymaster", "client_config.yml"), "The filename of the configuration") - rootCAFilename = flag.String("rootCAFilename", "", "(optional) name for using non OS root CA to verify TLS connections") - configHost = flag.String("configHost", "", "Get a bootstrap config from this host") - cliUsername = flag.String("username", "", "username for keymaster") - checkDevices = flag.Bool("checkDevices", false, "CheckU2F devices in your system") - cliFilePrefix = flag.String("fileprefix", "", "Prefix for the output files") + configFilename = flag.String("config", + filepath.Join(getUserHomeDir(), keymasterSubdir, "client_config.yml"), + "The filename of the configuration") + rootCAFilename = flag.String("rootCAFilename", "", + "(optional) name for using non OS root CA to verify TLS connections") + configHost = flag.String("configHost", "", + "Get a bootstrap config from this host") + cliUsername = flag.String("username", "", "username for keymaster") + checkDevices = flag.Bool("checkDevices", false, + "CheckU2F devices in your system") + cliFilePrefix = flag.String("fileprefix", "", + "Prefix for the output files") roundRobinDialer = flag.Bool("roundRobinDialer", false, "If true, use the smart round-robin dialer") + webauthBrowser = flag.String("webauthBrowser", "", + "Browser command to use for webauth") FilePrefix = "keymaster" ) @@ -189,15 +201,102 @@ func backgroundConnectToAnyKeymasterServer(targetUrls []string, client *http.Cli const rsaKeySize = 2048 +func generateAwsRoleCert(homeDir string, + configContents config.AppConfigFile, + client *http.Client, + logger log.DebugLogger) error { + signers := makeSigners() + // Initialise the client connection. + targetURLs := strings.Split(configContents.Base.Gen_Cert_URLS, ",") + err := backgroundConnectToAnyKeymasterServer(targetURLs, client, logger) + if err != nil { + return err + } + if err := makeDirs(homeDir); err != nil { + return err + } + tlsKeyPath := filepath.Join(homeDir, DefaultTLSKeysLocation, FilePrefix) + if err := signers.Wait(); err != nil { + return err + } + manager, err := aws_role.NewManager(aws_role.Params{ + KeymasterServer: targetURLs[0], + Logger: logger, + HttpClient: client, + Signer: signers.X509Rsa, + }) + if err != nil { + return err + } + encodedx509Signer, err := x509.MarshalPKCS8PrivateKey(signers.X509Rsa) + if err != nil { + return err + } + err = ioutil.WriteFile( + tlsKeyPath+".key", + pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: encodedx509Signer}), + 0600) + if err != nil { + return err + } + x509CertPath := tlsKeyPath + ".cert" + certPEM, _, err := manager.GetRoleCertificate() + if err != nil { + return err + } + if err := ioutil.WriteFile(x509CertPath, certPEM, 0644); err != nil { + return errors.New("Could not write ssh cert") + } + for { + logger.Println("starting loop waiting for certificate refreshes") + manager.WaitForRefresh() + certPEM, _, err := manager.GetRoleCertificate() + if err != nil { + return err + } + tempPath := x509CertPath + "~" + err = ioutil.WriteFile(tempPath, certPEM, 0644) + if err != nil { + return errors.New("Could not write ssh cert") + } + defer os.Remove(tempPath) + return os.Rename(tempPath, x509CertPath) + + } + return nil +} + // Beware, this function has inverted path.... at the beggining func insertSSHCertIntoAgentORWriteToFilesystem(certText []byte, signer interface{}, filePrefix string, userName string, privateKeyPath string, + confirmBeforeUse bool, logger log.DebugLogger) (err error) { + + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certText) + if err != nil { + logger.Println(err) + return err + } + sshCert, ok := pubKey.(*ssh.Certificate) + if !ok { + return fmt.Errorf("It is not a certificate") + } + comment := filePrefix + "-" + userName + keyToAdd := agent.AddedKey{ + PrivateKey: signer, + Certificate: sshCert, + Comment: comment, + LifetimeSecs: uint32((*twofa.Duration).Seconds()), + ConfirmBeforeUse: confirmBeforeUse, + } + //comment should be based on key type? - err = sshagent.UpsertCertIntoAgent(certText, signer, filePrefix+"-"+userName, uint32((*twofa.Duration).Seconds()), logger) + err = sshagent.WithAddedKeyUpsertCertIntoAgent(keyToAdd, logger) if err == nil { return nil } @@ -206,7 +305,10 @@ func insertSSHCertIntoAgentORWriteToFilesystem(certText []byte, // barfs on timeouts missing, so we rety without a timeout in case // we are on windows OR we have an agent running on windows thar is forwarded // to us. - err = sshagent.UpsertCertIntoAgent(certText, signer, filePrefix+"-"+userName, 0, logger) + keyToAdd.LifetimeSecs = 0 + // confirmation is also broken on windows, but since it is an opt-in security + // feature we never change the user preference + err = sshagent.WithAddedKeyUpsertCertIntoAgent(keyToAdd, logger) if err == nil { return nil } @@ -227,104 +329,121 @@ func insertSSHCertIntoAgentORWriteToFilesystem(certText []byte, return ioutil.WriteFile(sshCertPath, certText, 0644) } +func makeDirs(homeDir string) error { + sshKeyPath := filepath.Join(homeDir, DefaultSSHKeysLocation, FilePrefix) + sshConfigPath, _ := filepath.Split(sshKeyPath) + if err := os.MkdirAll(sshConfigPath, 0700); err != nil { + return err + } + tlsKeyPath := filepath.Join(homeDir, DefaultTLSKeysLocation, FilePrefix) + tlsConfigPath, _ := filepath.Split(tlsKeyPath) + if err := os.MkdirAll(tlsConfigPath, 0700); err != nil { + return err + } + return nil +} + func setupCerts( userName string, homeDir string, configContents config.AppConfigFile, client *http.Client, logger log.DebugLogger) error { + signers := makeSigners() //initialize the client connection targetURLs := strings.Split(configContents.Base.Gen_Cert_URLS, ",") err := backgroundConnectToAnyKeymasterServer(targetURLs, client, logger) if err != nil { return err } - - // create dirs - sshKeyPath := filepath.Join(homeDir, DefaultSSHKeysLocation, FilePrefix) - sshConfigPath, _ := filepath.Split(sshKeyPath) - err = os.MkdirAll(sshConfigPath, 0700) - if err != nil { + if err := makeDirs(homeDir); err != nil { return err } + sshKeyPath := filepath.Join(homeDir, DefaultSSHKeysLocation, FilePrefix) tlsKeyPath := filepath.Join(homeDir, DefaultTLSKeysLocation, FilePrefix) - tlsConfigPath, _ := filepath.Split(tlsKeyPath) - err = os.MkdirAll(tlsConfigPath, 0700) - if err != nil { - return err - } - // Setup Signers - x509Signer, err := rsa.GenerateKey(rand.Reader, rsaKeySize) - if err != nil { - return err - } - sshRsaSigner, err := rsa.GenerateKey(rand.Reader, rsaKeySize) - if err != nil { - return err + var baseUrl string + _webauthBrowser := configContents.Base.WebauthBrowser + if *webauthBrowser != "" { + _webauthBrowser = *webauthBrowser + } + if _webauthBrowser != "" { + // Authenticate using web browser. + baseUrl, err = webauth.Authenticate(userName, _webauthBrowser, + filepath.Join(homeDir, keymasterSubdir, FilePrefix+".webtoken"), + targetURLs, client, userAgentString, logger) + if err != nil { + return err + } + } else { + // Authenticate using password and possible 2nd factor. + password, err := util.GetUserCreds(userName) + if err != nil { + return err + } + baseUrl, err = twofa.AuthenticateToTargetUrls(userName, password, + targetURLs, false, client, + userAgentString, logger) + if err != nil { + return err + + } } - _, sshEd25519Signer, err := ed25519.GenerateKey(rand.Reader) - if err != nil { + if err := signers.Wait(); err != nil { return err } - // Get user creds - password, err := util.GetUserCreds(userName) + x509Cert, err := twofa.DoCertRequest(signers.X509Rsa, client, userName, + baseUrl, "x509", configContents.Base.AddGroups, userAgentString, logger) if err != nil { return err } - - baseUrl, err := twofa.AuthenticateToTargetUrls(userName, password, - targetURLs, false, client, + kubernetesCert, err := twofa.DoCertRequest(signers.X509Rsa, client, + userName, baseUrl, "x509-kubernetes", configContents.Base.AddGroups, userAgentString, logger) - if err != nil { - return err - - } - x509Cert, err := twofa.DoCertRequest(x509Signer, client, userName, baseUrl, "x509", - configContents.Base.AddGroups, userAgentString, logger) - if err != nil { - return err - } - kubernetesCert, err := twofa.DoCertRequest(x509Signer, client, userName, baseUrl, "x509-kubernetes", - configContents.Base.AddGroups, userAgentString, logger) if err != nil { logger.Debugf(0, "kubernetes cert not available") } - sshRsaCert, err := twofa.DoCertRequest(sshRsaSigner, client, userName, baseUrl, "ssh", - configContents.Base.AddGroups, userAgentString, logger) + sshRsaCert, err := twofa.DoCertRequest(signers.SshRsa, client, userName, + baseUrl, "ssh", configContents.Base.AddGroups, userAgentString, logger) if err != nil { return err } - sshEd25519Cert, err := twofa.DoCertRequest(sshEd25519Signer, client, userName, baseUrl, "ssh", - configContents.Base.AddGroups, userAgentString, logger) + sshEd25519Cert, err := twofa.DoCertRequest(signers.SshEd25519, client, + userName, baseUrl, "ssh", configContents.Base.AddGroups, + userAgentString, logger) if err != nil { logger.Debugf(1, "Ed25519 cert not available") sshEd25519Cert = nil } logger.Debugf(0, "certificates successfully generated") + confirmKeyUse := configContents.Base.AgentConfirmUse // Time to write certs and keys - err = insertSSHCertIntoAgentORWriteToFilesystem(sshRsaCert, - sshRsaSigner, - FilePrefix+"-rsa", - userName, - sshKeyPath+"-rsa", - logger) - if err != nil { - return err - } + // old agents do not understand sha2 certs, so we inject Ed25519 first + // if present if sshEd25519Cert != nil { err = insertSSHCertIntoAgentORWriteToFilesystem(sshEd25519Cert, - sshEd25519Signer, + signers.SshEd25519, FilePrefix+"-ed25519", userName, sshKeyPath+"-ed25519", + confirmKeyUse, logger) if err != nil { return err } } + err = insertSSHCertIntoAgentORWriteToFilesystem(sshRsaCert, + signers.SshRsa, + FilePrefix+"-rsa", + userName, + sshKeyPath+"-rsa", + confirmKeyUse, + logger) + if err != nil { + return err + } // Now x509 - encodedx509Signer, err := x509.MarshalPKCS8PrivateKey(x509Signer) + encodedx509Signer, err := x509.MarshalPKCS8PrivateKey(signers.X509Rsa) if err != nil { return err } @@ -385,8 +504,8 @@ func getHttpClient(rootCAs *x509.CertPool, logger log.DebugLogger) (*http.Client } func Usage() { - fmt.Fprintf( - os.Stderr, "Usage of %s (version %s):\n", os.Args[0], Version) + fmt.Fprintf(os.Stderr, "Usage: %s [flags...] [aws-role-cert]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Version: %s\n", Version) flag.PrintDefaults() } @@ -402,19 +521,17 @@ func main() { if err != nil { logger.Fatal(err) } - if *checkDevices { u2f.CheckU2FDevices(logger) return } computeUserAgent() - userName, homeDir, err := getUserNameAndHomeDir(logger) if err != nil { logger.Fatal(err) } config := loadConfigFile(client, logger) - + logger.Debugf(3, "loaded Config=%+v", config) // Adjust user name if len(config.Base.Username) > 0 { userName = config.Base.Username @@ -423,15 +540,17 @@ func main() { if *cliUsername != "" { userName = *cliUsername } - if len(config.Base.FilePrefix) > 0 { FilePrefix = config.Base.FilePrefix } if *cliFilePrefix != "" { FilePrefix = *cliFilePrefix } - - err = setupCerts(userName, homeDir, config, client, logger) + if flag.Arg(0) == "aws-role-cert" { + err = generateAwsRoleCert(homeDir, config, client, logger) + } else { + err = setupCerts(userName, homeDir, config, client, logger) + } if err != nil { logger.Fatal(err) } diff --git a/cmd/keymaster/main_test.go b/cmd/keymaster/main_test.go index 9caae10b..df2ce501 100644 --- a/cmd/keymaster/main_test.go +++ b/cmd/keymaster/main_test.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "testing" "time" @@ -240,6 +241,14 @@ func TestInsertSSHCertIntoAgentORWriteToFilesystem(t *testing.T) { if err != nil { t.Fatal(err) } + // This test needs a running agent... and remote windows + // builders do NOT have this... thus we need to abort this test + // until we have a way to NOT timeout on missing agent in + // windows + if runtime.GOOS == "windows" { + return + } + /////////Now actually do the work oldSSHSock, ok := os.LookupEnv("SSH_AUTH_SOCK") if ok { @@ -258,6 +267,7 @@ func TestInsertSSHCertIntoAgentORWriteToFilesystem(t *testing.T) { "someprefix", "username", privateKeyPath, + false, testlogger.New(t)) if err != nil { t.Fatal(err) diff --git a/cmd/keymaster/signers.go b/cmd/keymaster/signers.go new file mode 100644 index 00000000..c09ba0fd --- /dev/null +++ b/cmd/keymaster/signers.go @@ -0,0 +1,52 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "sync" +) + +type signers struct { + mutex sync.RWMutex + err error + X509Rsa *rsa.PrivateKey + SshRsa *rsa.PrivateKey + SshEd25519 ed25519.PrivateKey +} + +func makeSigners() *signers { + s := signers{} + s.mutex.Lock() + go s.compute() + return &s +} + +func (s *signers) compute() { + defer s.mutex.Unlock() + x509Signer, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + s.err = err + return + } + sshRsaSigner, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + s.err = err + return + } + _, sshEd25519Signer, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + s.err = err + return + } + s.X509Rsa = x509Signer + s.SshRsa = sshRsaSigner + s.SshEd25519 = sshEd25519Signer +} + +// Wait must be called before accessing any of the signers. +func (s *signers) Wait() error { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.err +} diff --git a/cmd/keymasterd/2fa_bootstrapOTP.go b/cmd/keymasterd/2fa_bootstrapOTP.go index 22f43e67..627c77e4 100644 --- a/cmd/keymasterd/2fa_bootstrapOTP.go +++ b/cmd/keymasterd/2fa_bootstrapOTP.go @@ -32,12 +32,12 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter, "Error parsing form") return } - authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { state.logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) var inputOtpHash [sha512.Size]byte if val, ok := r.Form["OTP"]; !ok { state.writeFailureResponse(w, r, http.StatusBadRequest, @@ -53,7 +53,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter, } inputOtpHash = sha512.Sum512([]byte(val[0])) } - profile, _, fromCache, err := state.LoadUserProfile(authUser) + profile, _, fromCache, err := state.LoadUserProfile(authData.Username) if err != nil { state.logger.Printf("error loading user profile err=%s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, @@ -73,7 +73,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter, } if subtle.ConstantTimeCompare(inputOtpHash[:], requiredOtpHash) != 1 { state.logger.Debugf(0, "Invalid Bootstrap OTP value for %s\n", - authUser) + authData.Username) var tmp [sha512.Size]byte copy(tmp[:], requiredOtpHash) state.logger.Debugf(4, " input: \"%v\" required: \"%v\"\n", @@ -83,13 +83,13 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter, return } profile.BootstrapOTP = bootstrapOTPData{} - if err := state.SaveUserProfile(authUser, profile); err != nil { + if err := state.SaveUserProfile(authData.Username, profile); err != nil { state.logger.Printf("error saving profile randr=%s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "") return } _, err = state.updateAuthCookieAuthlevel(w, r, - currentAuthLevel|AuthTypeBootstrapOTP) + authData.AuthType|AuthTypeBootstrapOTP) if err != nil { logger.Printf("Auth Cookie NOT found ? %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, @@ -97,7 +97,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter, return } // eventNotifier.PublishBootstrapOtpAuthEvent(eventmon.AuthTypeBootstrapOTP, - // authUser) + // authData.Username) // Now we send the user to the appropriate place returnAcceptType := getPreferredAcceptType(r) // TODO: The cert backend should depend also on per user preferences. @@ -105,7 +105,7 @@ func (state *RuntimeState) BootstrapOtpAuthHandler(w http.ResponseWriter, switch returnAcceptType { case "text/html": loginDestination := getLoginDestination(r) - eventNotifier.PublishWebLoginEvent(authUser) + eventNotifier.PublishWebLoginEvent(authData.Username) state.logger.Debugf(0, "redirecting to: %s\n", loginDestination) http.Redirect(w, r, loginDestination, 302) default: diff --git a/cmd/keymasterd/2fa_okta.go b/cmd/keymasterd/2fa_okta.go index 1acf424f..dea30ea9 100644 --- a/cmd/keymasterd/2fa_okta.go +++ b/cmd/keymasterd/2fa_okta.go @@ -83,19 +83,19 @@ func (state *RuntimeState) oktaPushStartHandler(w http.ResponseWriter, r *http.R state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") return } - authUser, _, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) oktaAuth, ok := state.passwordChecker.(*okta.PasswordAuthenticator) if !ok { logger.Debugf(2, "oktaPushStartHandler: password authenticator is not okta is of type %T", oktaAuth) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Apperent Misconfiguration") return } - pushResponse, err := oktaAuth.ValidateUserPush(authUser) + pushResponse, err := oktaAuth.ValidateUserPush(authData.Username) if err != nil { logger.Println(err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating OKTA push") @@ -112,6 +112,7 @@ func (state *RuntimeState) oktaPushStartHandler(w http.ResponseWriter, r *http.R } func (state *RuntimeState) oktaPollCheckHandler(w http.ResponseWriter, r *http.Request) { + logger.Debugf(3, "top of oktaPollCheckHandler") if state.sendFailureToClientIfLocked(w, r) { return } @@ -119,19 +120,19 @@ func (state *RuntimeState) oktaPollCheckHandler(w http.ResponseWriter, r *http.R state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") return } - authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) oktaAuth, ok := state.passwordChecker.(*okta.PasswordAuthenticator) if !ok { logger.Println("password authenticator is not okta") state.writeFailureResponse(w, r, http.StatusInternalServerError, "Apperent Misconfiguration") return } - pushResponse, err := oktaAuth.ValidateUserPush(authUser) + pushResponse, err := oktaAuth.ValidateUserPush(authData.Username) if err != nil { logger.Println(err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating OKTA push") @@ -141,12 +142,14 @@ func (state *RuntimeState) oktaPollCheckHandler(w http.ResponseWriter, r *http.R case okta.PushResponseApproved: // TODO: add notification on eventmond metricLogAuthOperation(getClientType(r), proto.AuthTypeOkta2FA, true) - _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeOkta2FA) + _, err = state.updateAuthCookieAuthlevel(w, r, + authData.AuthType|AuthTypeOkta2FA) if err != nil { logger.Printf("Auth Cookie NOT found ? %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating Okta token") return } + logger.Debugf(2, "oktaPollCheckHandler success") w.WriteHeader(http.StatusOK) return case okta.PushResponseWaiting: diff --git a/cmd/keymasterd/2fa_totp.go b/cmd/keymasterd/2fa_totp.go index c0eb8fc4..a5af1b74 100644 --- a/cmd/keymasterd/2fa_totp.go +++ b/cmd/keymasterd/2fa_totp.go @@ -74,18 +74,18 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques return } // TODO: think if we are going to allow admins to register these tokens - authUser, _, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) // TODO: check if TOTP is even enabled. // TODO: check for method, we should only allow POST requests - profile, _, fromCache, err := state.LoadUserProfile(authUser) + profile, _, fromCache, err := state.LoadUserProfile(authData.Username) if err != nil { logger.Printf("loading profile error: %v", err) http.Error(w, "error", http.StatusInternalServerError) @@ -100,7 +100,7 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques key, err := totp.Generate(totp.GenerateOpts{ Issuer: state.HostIdentity, - AccountName: authUser, + AccountName: authData.Username, }) if err != nil { logger.Printf("generating new key error: %v", err) @@ -114,7 +114,7 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques return } profile.PendingTOTPSecret = &encryptedKeys - err = state.SaveUserProfile(authUser, profile) + err = state.SaveUserProfile(authData.Username, profile) if err != nil { logger.Printf("Saving profile error: %v", err) http.Error(w, "error", http.StatusInternalServerError) @@ -133,7 +133,8 @@ func (state *RuntimeState) GenerateNewTOTP(w http.ResponseWriter, r *http.Reques // We need custom CSP policy to allow embedded images w.Header().Set("Content-Security-Policy", "default-src 'self' ;img-src 'self' data: ;style-src 'self' fonts.googleapis.com 'unsafe-inline'; font-src fonts.gstatic.com fonts.googleapis.com") displayData := newTOTPPageTemplateData{ - AuthUsername: authUser, + AuthUsername: authData.Username, + SessionExpires: authData.expires(), Title: "New TOTP Generation", //TODO: maybe include username? TOTPSecret: key.Secret(), TOTPBase64Image: template.HTML("\"beastie.png\""), @@ -233,19 +234,21 @@ func (state *RuntimeState) validateNewTOTP(w http.ResponseWriter, r *http.Reques const totpTokenManagementPath = "/api/v0/manageTOTPToken" -func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *http.Request) { +func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, + r *http.Request) { // User must be logged in if state.sendFailureToClientIfLocked(w, r) { return } - // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + // TODO(camilo_viecco1): reorder checks so that simple checks are done + // before checking user creds + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { logger.Debugf(1, "%v", err) http.Error(w, "error", http.StatusInternalServerError) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) // TODO: ensure is a valid method (POST) if r.Method != "POST" { logger.Printf("Wanted Post got='%s'", r.Method) @@ -255,7 +258,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt err = r.ParseForm() if err != nil { logger.Println(err) - state.writeFailureResponse(w, r, http.StatusBadRequest, "Error parsing form") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Error parsing form") return } logger.Debugf(3, "Form: %+v", r.Form) @@ -263,11 +267,13 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt assumedUser := r.Form.Get("username") // Have admin rights = Must be admin + authenticated with U2F - hasAdminRights := state.IsAdminUserAndU2F(authUser, loginLevel) + hasAdminRights := state.IsAdminUserAndU2F(authData.Username, + authData.AuthType) // Check params - if !hasAdminRights && assumedUser != authUser { - logger.Printf("bad username authUser=%s requested=%s", authUser, r.Form.Get("username")) + if !hasAdminRights && assumedUser != authData.Username { + logger.Printf("bad username authUser=%s requested=%s", + authData.Username, r.Form.Get("username")) state.writeFailureResponse(w, r, http.StatusUnauthorized, "") return } @@ -275,7 +281,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt tokenIndex, err := strconv.ParseInt(r.Form.Get("index"), 10, 64) if err != nil { logger.Printf("tokenindex is not a number") - state.writeFailureResponse(w, r, http.StatusBadRequest, "tokenindex is not a number") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "tokenindex is not a number") return } @@ -289,7 +296,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt } if fromCache { logger.Printf("DB is being cached and requesting registration aborting it") - http.Error(w, "db backend is offline for writes", http.StatusServiceUnavailable) + http.Error(w, "db backend is offline for writes", + http.StatusServiceUnavailable) return } @@ -297,7 +305,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt _, ok := profile.TOTPAuthData[tokenIndex] if !ok { logger.Printf("bad index number") - state.writeFailureResponse(w, r, http.StatusBadRequest, "bad index Value") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "bad index Value") return } actionName := r.Form.Get("action") @@ -306,7 +315,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt tokenName := r.Form.Get("name") if m, _ := regexp.MatchString("^[-/.a-zA-Z0-9_ ]+$", tokenName); !m { logger.Printf("%s", tokenName) - state.writeFailureResponse(w, r, http.StatusBadRequest, "invalidtokenName") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "invalidtokenName") return } profile.TOTPAuthData[tokenIndex].Name = tokenName @@ -317,7 +327,8 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt case "Delete": delete(profile.TOTPAuthData, tokenIndex) default: - state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid Operation") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Invalid Operation") return } err = state.SaveUserProfile(assumedUser, profile) @@ -331,7 +342,7 @@ func (state *RuntimeState) totpTokenManagerHandler(w http.ResponseWriter, r *htt returnAcceptType := getPreferredAcceptType(r) switch returnAcceptType { case "text/html": - http.Redirect(w, r, profileURI(authUser, assumedUser), 302) + http.Redirect(w, r, profileURI(authData.Username, assumedUser), 302) default: w.WriteHeader(200) fmt.Fprintf(w, "Success!") @@ -442,13 +453,13 @@ func (state *RuntimeState) commonTOTPPostHandler(w http.ResponseWriter, r *http. return "", 0, 0, errors.New("server still sealed") } // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, loginLevel, err := state.checkAuth(w, r, requiredAuthLevel) + authData, err := state.checkAuth(w, r, requiredAuthLevel) if err != nil { logger.Debugf(1, "%v", err) http.Error(w, "error", http.StatusInternalServerError) return "", 0, 0, err } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) // TODO: ensure is a valid method (POST) if r.Method != "POST" { logger.Printf("Wanted Post got='%s'", r.Method) @@ -478,7 +489,7 @@ func (state *RuntimeState) commonTOTPPostHandler(w http.ResponseWriter, r *http. return "", 0, 0, err } - return authUser, loginLevel, otpValue, nil + return authData.Username, authData.AuthType, otpValue, nil } const totpVerifyHandlerPath = "/api/v0/VerifyTOTP" diff --git a/cmd/keymasterd/2fa_u2f.go b/cmd/keymasterd/2fa_u2f.go index cd844b88..2ea26013 100644 --- a/cmd/keymasterd/2fa_u2f.go +++ b/cmd/keymasterd/2fa_u2f.go @@ -1,8 +1,11 @@ package main import ( + "crypto/ecdsa" + "crypto/elliptic" "encoding/json" "fmt" + "math/big" "net/http" "strings" "time" @@ -10,15 +13,63 @@ import ( "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" "github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto" "github.com/Cloud-Foundations/keymaster/proto/eventmon" + "github.com/duo-labs/webauthn/protocol/webauthncose" "github.com/tstranex/u2f" ) -//////////////////////////// -func getRegistrationArray(U2fAuthData map[int64]*u2fAuthData) (regArray []u2f.Registration) { - for _, data := range U2fAuthData { - if data.Enabled { - regArray = append(regArray, *data.Registration) +func webauthnRegistrationToU2fRegistration(reg webauthAuthData) (*u2fAuthData, error) { + x, y := elliptic.Unmarshal(elliptic.P256(), reg.Credential.PublicKey) + if x == nil || y == nil { + logger.Debugf(0, "cannot decode not native p256 curve") + cosePubkey, err := webauthncose.ParsePublicKey(reg.Credential.PublicKey) + if err != nil { + return nil, fmt.Errorf("not a webcose pub key either") } + logger.Debugf(0, "it is a cosePubkey of type %T ", cosePubkey) + + coseECKey, ok := cosePubkey.(webauthncose.EC2PublicKeyData) + if !ok { + return nil, fmt.Errorf("not an Cose EC2PublicKeyData") + } + if webauthncose.COSEAlgorithmIdentifier(coseECKey.Algorithm) != webauthncose.AlgES256 { + return nil, fmt.Errorf("not a P256 curve") + } + x = big.NewInt(0).SetBytes(coseECKey.XCoord) + y = big.NewInt(0).SetBytes(coseECKey.YCoord) + logger.Debugf(2, "webauthnRegistrationToU2fRegistration: cose p256 curve found") + } + registration := u2f.Registration{ + KeyHandle: reg.Credential.ID, + PubKey: ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: x, + Y: y, + }, + } + authData := u2fAuthData{ + Registration: ®istration, + Counter: reg.Credential.Authenticator.SignCount, + } + return &authData, nil +} + +func (u *userProfile) getRegistrationArray() (regArray []u2f.Registration) { + for _, data := range u.U2fAuthData { + if !data.Enabled { + continue + } + regArray = append(regArray, *data.Registration) + } + for _, webauth := range u.WebauthnData { + if !webauth.Enabled { + continue + } + u2fData, err := webauthnRegistrationToU2fRegistration(*webauth) + if err != nil { + logger.Debugf(3, " getRegistrationArray could not transform webauth err:%s", err) + continue + } + regArray = append(regArray, *u2fData.Registration) } return regArray } @@ -42,20 +93,17 @@ func (state *RuntimeState) u2fRegisterRequest(w http.ResponseWriter, r *http.Req return } - /* - - /* - */ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) // Check that they can change other users - if !state.IsAdminUserAndU2F(authUser, loginLevel) && authUser != assumedUser { + if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) && + authData.Username != assumedUser { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -80,7 +128,7 @@ func (state *RuntimeState) u2fRegisterRequest(w http.ResponseWriter, r *http.Req return } profile.RegistrationChallenge = c - registrations := getRegistrationArray(profile.U2fAuthData) + registrations := profile.getRegistrationArray() req := u2f.NewWebRegisterRequest(c, registrations) logger.Printf("registerRequest: %+v", req) @@ -115,15 +163,16 @@ func (state *RuntimeState) u2fRegisterResponse(w http.ResponseWriter, r *http.Re /* */ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) // Check that they can change other users - if !state.IsAdminUserAndU2F(authUser, loginLevel) && authUser != assumedUser { + if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) && + authData.Username != assumedUser { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -167,8 +216,8 @@ func (state *RuntimeState) u2fRegisterResponse(w http.ResponseWriter, r *http.Re CreatedAt: time.Now(), CreatorAddr: r.RemoteAddr, } - if authUser != assumedUser { - newReg.Name = fmt.Sprintf("Registered by %s", authUser) + if authData.Username != assumedUser { + newReg.Name = fmt.Sprintf("Registered by %s", authData.Username) } newIndex := newReg.CreatedAt.Unix() profile.U2fAuthData[newIndex] = &newReg @@ -194,15 +243,15 @@ func (state *RuntimeState) u2fSignRequest(w http.ResponseWriter, r *http.Request /* */ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, _, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) ////////// - profile, ok, _, err := state.LoadUserProfile(authUser) + profile, ok, _, err := state.LoadUserProfile(authData.Username) if err != nil { logger.Printf("loading profile error: %v", err) http.Error(w, "error", http.StatusInternalServerError) @@ -211,10 +260,10 @@ func (state *RuntimeState) u2fSignRequest(w http.ResponseWriter, r *http.Request ///////// if !ok { - http.Error(w, "No regstered data", http.StatusBadRequest) + http.Error(w, "No registered data", http.StatusBadRequest) return } - registrations := getRegistrationArray(profile.U2fAuthData) + registrations := profile.getRegistrationArray() if len(registrations) < 1 { http.Error(w, "registration missing", http.StatusBadRequest) return @@ -232,7 +281,7 @@ func (state *RuntimeState) u2fSignRequest(w http.ResponseWriter, r *http.Request localAuth.U2fAuthChallenge = c localAuth.ExpiresAt = time.Now().Add(maxAgeU2FVerifySeconds * time.Second) state.Mutex.Lock() - state.localAuthData[authUser] = localAuth + state.localAuthData[authData.Username] = localAuth state.Mutex.Unlock() req := c.SignRequest(registrations) @@ -255,12 +304,12 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques /* */ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) //now the actual work var signResp u2f.SignResponse @@ -271,7 +320,7 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques logger.Debugf(1, "signResponse: %+v", signResp) - profile, ok, _, err := state.LoadUserProfile(authUser) + profile, ok, _, err := state.LoadUserProfile(authData.Username) if err != nil { logger.Printf("loading profile error: %v", err) http.Error(w, "error", http.StatusInternalServerError) @@ -284,7 +333,7 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques http.Error(w, "No regstered data", http.StatusBadRequest) return } - registrations := getRegistrationArray(profile.U2fAuthData) + registrations := profile.getRegistrationArray() if len(registrations) < 1 { http.Error(w, "registration missing", http.StatusBadRequest) return @@ -295,7 +344,7 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques return } state.Mutex.Lock() - localAuth, ok := state.localAuthData[authUser] + localAuth, ok := state.localAuthData[authData.Username] state.Mutex.Unlock() if !ok { http.Error(w, "challenge missing", http.StatusBadRequest) @@ -304,7 +353,9 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques //var err error for i, u2fReg := range profile.U2fAuthData { - //newCounter, authErr := u2fReg.Registration.Authenticate(signResp, *profile.U2fAuthChallenge, u2fReg.Counter) + if !u2fReg.Enabled { + continue + } newCounter, authErr := u2fReg.Registration.Authenticate(signResp, *localAuth.U2fAuthChallenge, u2fReg.Counter) if authErr == nil { metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, true) @@ -316,14 +367,15 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques u2fReg.Counter = newCounter profile.U2fAuthData[i] = u2fReg //profile.U2fAuthChallenge = nil - delete(state.localAuthData, authUser) + delete(state.localAuthData, authData.Username) - eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authUser) + eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authData.Username) _, isXHR := r.Header["X-Requested-With"] if isXHR { - eventNotifier.PublishWebLoginEvent(authUser) + eventNotifier.PublishWebLoginEvent(authData.Username) } - _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeU2F) + _, err = state.updateAuthCookieAuthlevel(w, r, + authData.AuthType|AuthTypeU2F) if err != nil { logger.Printf("Auth Cookie NOT found ? %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure updating vip token") @@ -335,6 +387,37 @@ func (state *RuntimeState) u2fSignResponse(w http.ResponseWriter, r *http.Reques return } } + // test1: transform webahtn registration into u2f one + for _, webauthnData := range profile.WebauthnData { + if !webauthnData.Enabled { + continue + } + u2fReg, err := webauthnRegistrationToU2fRegistration(*webauthnData) + if err != nil { + logger.Debugf(2, "cannot transform, err:%s", err) + continue + } + newCounter, authErr := u2fReg.Registration.Authenticate(signResp, *localAuth.U2fAuthChallenge, u2fReg.Counter) + if authErr == nil { + metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, true) + logger.Debugf(0, "newCounter: %d", newCounter) + eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authData.Username) + _, isXHR := r.Header["X-Requested-With"] + if isXHR { + eventNotifier.PublishWebLoginEvent(authData.Username) + } + _, err = state.updateAuthCookieAuthlevel(w, r, + authData.AuthType|AuthTypeU2F) + if err != nil { + logger.Printf("Auth Cookie NOT found ? %s", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure updating vip token") + return + } + // TODO: update local cookie state + w.Write([]byte("success")) + return + } + } metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, false) logger.Printf("VerifySignResponse error: %v", err) diff --git a/cmd/keymasterd/2fa_vip.go b/cmd/keymasterd/2fa_vip.go index 72cdcca3..fad3659b 100644 --- a/cmd/keymasterd/2fa_vip.go +++ b/cmd/keymasterd/2fa_vip.go @@ -57,12 +57,12 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request return } //authUser, authType, err := state.checkAuth(w, r, AuthTypeAny) - authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) var OTPString string if val, ok := r.Form["OTP"]; ok { @@ -86,7 +86,7 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request } start := time.Now() - valid, err := state.Config.SymantecVIP.Client.ValidateUserOTP(authUser, otpValue) + valid, err := state.Config.SymantecVIP.Client.ValidateUserOTP(authData.Username, otpValue) if err != nil { logger.Println(err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating VIP token") @@ -98,7 +98,7 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request // metricLogAuthOperation(getClientType(r), proto.AuthTypeSymantecVIP, valid) if !valid { - logger.Printf("Invalid VIP OTP value login for %s", authUser) + logger.Printf("Invalid VIP OTP value login for %s", authData.Username) // TODO if client is html then do a redirect back to vipLoginPage state.writeFailureResponse(w, r, http.StatusUnauthorized, "") return @@ -106,9 +106,10 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request } // OTP check was successful - logger.Debugf(1, "Successful vipOTP auth for user: %s", authUser) - eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypeOTP, authUser) - _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeSymantecVIP) + logger.Debugf(1, "Successful vipOTP auth for user: %s", authData.Username) + eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypeOTP, authData.Username) + _, err = state.updateAuthCookieAuthlevel(w, r, + authData.AuthType|AuthTypeSymantecVIP) if err != nil { logger.Printf("Auth Cookie NOT found ? %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating VIP token") @@ -123,7 +124,7 @@ func (state *RuntimeState) VIPAuthHandler(w http.ResponseWriter, r *http.Request switch returnAcceptType { case "text/html": loginDestination := getLoginDestination(r) - eventNotifier.PublishWebLoginEvent(authUser) + eventNotifier.PublishWebLoginEvent(authData.Username) http.Redirect(w, r, loginDestination, 302) default: w.WriteHeader(200) @@ -152,13 +153,13 @@ func (state *RuntimeState) vipPushStartHandler(w http.ResponseWriter, r *http.Re state.writeFailureResponse(w, r, http.StatusBadRequest, "") return } - authUser, _, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) - logger.Debugf(0, "Vip push start authuser=%s", authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + logger.Debugf(0, "Vip push start authuser=%s", authData.Username) vipPushCookie, err := r.Cookie(vipTransactionCookieName) if err != nil { logger.Printf("%v", err) @@ -178,7 +179,7 @@ func (state *RuntimeState) vipPushStartHandler(w http.ResponseWriter, r *http.Re state.writeFailureResponse(w, r, http.StatusPreconditionFailed, "Push already sent") return } - err = state.startVIPPush(vipPushCookie.Value, authUser) + err = state.startVIPPush(vipPushCookie.Value, authData.Username) if err != nil { logger.Println(err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Cookie not setup ") @@ -224,13 +225,13 @@ func (state *RuntimeState) VIPPollCheckHandler(w http.ResponseWriter, r *http.Re state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") return } - authUser, currentAuthLevel, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) - logger.Debugf(1, "VIPPollCheckHandler: authuser=%s", authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + logger.Debugf(1, "VIPPollCheckHandler: authuser=%s", authData.Username) vipPollCookie, err := r.Cookie(vipTransactionCookieName) if err != nil { logger.Printf("VIPPollCheckHandler: error getting poll cookie %v", err) @@ -259,13 +260,15 @@ func (state *RuntimeState) VIPPollCheckHandler(w http.ResponseWriter, r *http.Re } // VIP Push check was successful - _, err = state.updateAuthCookieAuthlevel(w, r, currentAuthLevel|AuthTypeSymantecVIP) + _, err = state.updateAuthCookieAuthlevel(w, r, + authData.AuthType|AuthTypeSymantecVIP) if err != nil { logger.Printf("VIPPollCheckHandler: Failure to update AuthCookie %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure when validating VIP token") return } - eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypePush, authUser) + eventNotifier.PublishVIPAuthEvent(eventmon.VIPAuthTypePush, + authData.Username) // TODO make something more fancy: JSON? w.WriteHeader(http.StatusOK) diff --git a/cmd/keymasterd/2fa_webauthn.go b/cmd/keymasterd/2fa_webauthn.go new file mode 100644 index 00000000..7b010432 --- /dev/null +++ b/cmd/keymasterd/2fa_webauthn.go @@ -0,0 +1,369 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/tstranex/u2f" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + + "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" + "github.com/Cloud-Foundations/keymaster/proto/eventmon" +) + +// from: https://github.com/duo-labs/webauthn.io/blob/3f03b482d21476f6b9fb82b2bf1458ff61a61d41/server/response.go#L15 +func webauthnJsonResponse(w http.ResponseWriter, d interface{}, c int) { + dj, err := json.Marshal(d) + if err != nil { + http.Error(w, "Error creating JSON response", http.StatusInternalServerError) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(c) + logger.Debugf(3, "webauth json response=%s", dj) + fmt.Fprintf(w, "%s", dj) +} + +const webAutnRegististerRequestPath = "/webauthn/RegisterRequest/" + +// RegisterRequest? +func (state *RuntimeState) webauthnBeginRegistration(w http.ResponseWriter, r *http.Request) { + logger.Debugf(3, "top of webauthnBeginRegistration") + if state.sendFailureToClientIfLocked(w, r) { + return + } + + // /u2f/RegisterRequest/ + // pieces[0] == "" pieces[1] = "u2f" pieces[2] == "RegisterRequest" + pieces := strings.Split(r.URL.Path, "/") + + var assumedUser string + if len(pieces) >= 4 { + assumedUser = pieces[3] + } else { + logger.Debugf(1, "webauthnBeginRegistration: bad number of pieces") + http.Error(w, "error", http.StatusBadRequest) + return + } + // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + if err != nil { + logger.Debugf(1, "%v", err) + return + } + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + + // Check that they can change other users + if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) && + authData.Username != assumedUser { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + profile, _, fromCache, err := state.LoadUserProfile(assumedUser) + if err != nil { + logger.Printf("webauthnBeginRegistration: loading profile error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + + } + if fromCache { + logger.Printf("DB is being cached and requesting registration aborting it") + http.Error(w, "db backend is offline for writes", http.StatusServiceUnavailable) + return + } + + profile.FixupCredential(assumedUser, assumedUser) + logger.Debugf(2, "webauthnBeginRegistration profile=%+v", profile) + logger.Debugf(2, "webauthnBeginRegistration: About to begin BeginRegistration") + options, sessionData, err := state.webAuthn.BeginRegistration(profile) + if err != nil { + state.logger.Printf("webauthnBeginRegistration: begin login failed %s", err) + // TODO: we should not be sending ALL the errors to clients + webauthnJsonResponse(w, err.Error(), http.StatusInternalServerError) + return + } + profile.WebauthnSessionData = sessionData + err = state.SaveUserProfile(assumedUser, profile) + if err != nil { + logger.Printf("Saving profile error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + webauthnJsonResponse(w, options, http.StatusOK) +} + +const webAutnRegististerFinishPath = "/webauthn/RegisterFinish/" + +func (state *RuntimeState) webauthnFinishRegistration(w http.ResponseWriter, r *http.Request) { + logger.Debugf(3, "top of webauthnFinishRegistration") + if state.sendFailureToClientIfLocked(w, r) { + return + } + + // TODO: better pattern matching + // /u2f/RegisterRequest/ + // pieces[0] == "" pieces[1] = "u2f" pieces[2] == "RegisterRequest" + pieces := strings.Split(r.URL.Path, "/") + + var assumedUser string + if len(pieces) >= 4 { + assumedUser = pieces[3] + } else { + http.Error(w, "error", http.StatusBadRequest) + return + } + // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + if err != nil { + logger.Debugf(1, "%v", err) + return + } + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + + // Check that they can change other users + if !state.IsAdminUserAndU2F(authData.Username, authData.AuthType) && + authData.Username != assumedUser { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + profile, _, fromCache, err := state.LoadUserProfile(assumedUser) + if err != nil { + logger.Printf("loading profile error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + + } + if fromCache { + logger.Printf("DB is being cached and requesting registration aborting it") + http.Error(w, "db backend is offline for writes", http.StatusServiceUnavailable) + return + } + + // load the session data + credential, err := state.webAuthn.FinishRegistration(profile, *profile.WebauthnSessionData, r) + if err != nil { + state.logger.Println(err) + webauthnJsonResponse(w, err.Error(), http.StatusBadRequest) + return + } + logger.Debugf(2, "new credential=%+v\n", *credential) + + err = profile.AddWebAuthnCredential(*credential) + if err != nil { + logger.Printf("Saving adding credential error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + + err = state.SaveUserProfile(assumedUser, profile) + if err != nil { + logger.Printf("Saving profile error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + webauthnJsonResponse(w, "Registration Success", http.StatusOK) +} + +const webAuthnAuthBeginPath = "/webauthn/AuthBegin/" + +func (state *RuntimeState) webauthnAuthLogin(w http.ResponseWriter, r *http.Request) { + logger.Debugf(3, "top of webauthnAuthBegin") + if state.sendFailureToClientIfLocked(w, r) { + return + } + + // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds + authData, err := state.checkAuth(w, r, AuthTypeAny) + if err != nil { + logger.Debugf(1, "%v", err) + return + } + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + + profile, _, fromCache, err := state.LoadUserProfile(authData.Username) + if err != nil { + logger.Printf("loading profile error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + + } + if fromCache { + logger.Debugf(1, "DB is being cached and requesting authentication, proceeding with cached values") + } + + // TODO: there is an extension to ensure it is an actual secirity key... need to add this to the call. + extensions := protocol.AuthenticationExtensions{"appid": u2fAppID} + options, sessionData, err := state.webAuthn.BeginLogin(profile, + webauthn.WithAssertionExtensions(extensions)) + if err != nil { + logger.Printf("webauthnAuthBegin: %s", err) + webauthnJsonResponse(w, err.Error(), http.StatusInternalServerError) + return + } + + c, err := u2f.NewChallenge(u2fAppID, u2fTrustedFacets) + if err != nil { + logger.Printf("u2f.NewChallenge error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + c.Challenge, err = base64.RawURLEncoding.DecodeString(sessionData.Challenge) + if err != nil { + logger.Printf("webauthnAuthBegin base64 error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + + var localAuth localUserData + localAuth.U2fAuthChallenge = c + localAuth.WebAuthnChallenge = sessionData + localAuth.ExpiresAt = time.Now().Add(maxAgeU2FVerifySeconds * time.Second) + state.Mutex.Lock() + state.localAuthData[authData.Username] = localAuth + state.Mutex.Unlock() + + webauthnJsonResponse(w, options, http.StatusOK) + logger.Debugf(3, "end of webauthnAuthBegin") +} + +const webAuthnAuthFinishPath = "/webauthn/AuthFinish/" + +func (state *RuntimeState) webauthnAuthFinish(w http.ResponseWriter, r *http.Request) { + logger.Debugf(3, "top of webauthnAuthFinish") + if state.sendFailureToClientIfLocked(w, r) { + return + } + // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds + authData, err := state.checkAuth(w, r, AuthTypeAny) + if err != nil { + logger.Debugf(1, "%v", err) + return + } + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + profile, ok, _, err := state.LoadUserProfile(authData.Username) + if err != nil { + logger.Printf("loading profile error: %v", err) + http.Error(w, "error", http.StatusInternalServerError) + return + + } + if !ok { + http.Error(w, "No regstered data", http.StatusBadRequest) + return + } + + state.Mutex.Lock() + localAuth, ok := state.localAuthData[authData.Username] + state.Mutex.Unlock() + if !ok { + http.Error(w, "challenge missing", http.StatusBadRequest) + return + } + + parsedResponse, err := protocol.ParseCredentialRequestResponse(r) + if err != nil { + logger.Printf("Error parsing Response err =%s", err) + http.Error(w, "", http.StatusBadRequest) + return + } + + userCredentials := profile.WebAuthnCredentials() + var loginCredential webauthn.Credential + var credentialFound bool + var credentialIndex int64 + for _, cred := range userCredentials { + if cred.AttestationType != "fido-u2f" { + continue + } + if bytes.Equal(cred.ID, parsedResponse.RawID) { + loginCredential = cred + credentialFound = true + for i, u2fReg := range profile.U2fAuthData { + if !u2fReg.Enabled { + continue + } + if bytes.Equal(u2fReg.Registration.KeyHandle, parsedResponse.RawID) { + credentialIndex = i + } + } + + break + } + credentialFound = false + } + + verifiedAuth := authData.AuthType + if !credentialFound { + // DO STD webaautn verification + _, err = state.webAuthn.ValidateLogin(profile, *localAuth.WebAuthnChallenge, parsedResponse) // iFinishLogin(profile, *localAuth.WebAuthnChallenge, r) + if err != nil { + logger.Printf("webauthnAuthFinish: auth failure err=%s", err) + webauthnJsonResponse(w, err.Error(), http.StatusBadRequest) + return + } + + // TODO also update the profile with latest counter + verifiedAuth = AuthTypeFIDO2 + } else { + // NOTE: somehow the extensions for grabbing the appID are failing + // So we "unroll" the important pieces of webAuthn.ValidateLogin here, with + // explicit changes for our appID + // Notice that if we where strict we would iterate over all the alloowed values. + session := *localAuth.WebAuthnChallenge + shouldVerifyUser := session.UserVerification == protocol.VerificationRequired + + rpID := state.webAuthn.Config.RPID + rpOrigin := state.webAuthn.Config.RPOrigin + appID := u2fAppID + + // Handle steps 4 through 16 + validError := parsedResponse.Verify(session.Challenge, rpID, rpOrigin, appID, shouldVerifyUser, loginCredential.PublicKey) + if validError != nil { + logger.Printf("failed to verify webauthn parsedResponse") + state.writeFailureResponse(w, r, http.StatusUnauthorized, "Credential Not Found") + return + } + + //loginCredential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter) + u2fReg, ok := profile.U2fAuthData[credentialIndex] + if ok { + u2fReg.Counter = parsedResponse.Response.AuthenticatorData.Counter + profile.U2fAuthData[credentialIndex] = u2fReg + go state.SaveUserProfile(authData.Username, profile) + } + + verifiedAuth = AuthTypeU2F + logger.Debugf(3, "success (LOCAL)") + } + logger.Debugf(1, "webauthnAuthFinish: auth success") + + // TODO: disinguish better between the two protocols or just use one + //metricLogAuthOperation(getClientType(r), proto.AuthTypeU2F, true) + state.Mutex.Lock() + delete(state.localAuthData, authData.Username) + state.Mutex.Unlock() + + //TODO: distinguish here u2f vs webauthn + eventNotifier.PublishAuthEvent(eventmon.AuthTypeU2F, authData.Username) + _, isXHR := r.Header["X-Requested-With"] + if isXHR { + eventNotifier.PublishWebLoginEvent(authData.Username) + } + + _, err = state.updateAuthCookieAuthlevel(w, r, + authData.AuthType|verifiedAuth|AuthTypeU2F) + if err != nil { + logger.Printf("Auth Cookie NOT found ? %s", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "Failure updating vip token") + return + } + webauthnJsonResponse(w, "Login Success", http.StatusOK) +} diff --git a/cmd/keymasterd/adminHandlers.go b/cmd/keymasterd/adminHandlers.go index b380703d..189b152f 100644 --- a/cmd/keymasterd/adminHandlers.go +++ b/cmd/keymasterd/adminHandlers.go @@ -19,28 +19,29 @@ const generateBoostrapOTPPath = "/admin/newBoostrapOTP" const defaultBootstrapOTPDuration = 6 * time.Hour const maximumBootstrapOTPDuration = 24 * time.Hour -// Returns (true, "") if an error was sent, (false, adminUser) if an admin user. +// Returns (true, nil) if an error was sent, (false, *authInfo) if an admin +// user. func (state *RuntimeState) sendFailureToClientIfNonAdmin(w http.ResponseWriter, - r *http.Request) (bool, string) { + r *http.Request) (bool, *authInfo) { if state.sendFailureToClientIfLocked(w, r) { - return true, "" + return true, nil } // TODO: probably this should be just u2f and AuthTypeKeymasterX509... but // probably we want also to allow configurability for this. Leaving // AuthTypeKeymasterX509 as optional for now - authUser, _, err := state.checkAuth(w, r, + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()|AuthTypeKeymasterX509) if err != nil { state.logger.Debugf(1, "%v", err) - return true, "" + return true, nil } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) - if !state.IsAdminUser(authUser) { + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + if !state.IsAdminUser(authData.Username) { state.writeFailureResponse(w, r, http.StatusUnauthorized, "Not an admin user") - return true, "" + return true, nil } - return false, authUser + return false, authData } func (state *RuntimeState) ensurePostAndGetUsername(w http.ResponseWriter, @@ -84,23 +85,27 @@ func (state *RuntimeState) ensurePostAndGetUsername(w http.ResponseWriter, func (state *RuntimeState) usersHandler(w http.ResponseWriter, r *http.Request) { state.logger.Debugf(3, "Top of usersHandler r=%+v", r) - failure, authUser := state.sendFailureToClientIfNonAdmin(w, r) - if failure || authUser == "" { + failure, authData := state.sendFailureToClientIfNonAdmin(w, r) + if failure || authData == nil { return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) users, _, err := state.GetUsers() if err != nil { state.logger.Printf("Getting users error: %v", err) http.Error(w, "error", http.StatusInternalServerError) return } - JSSources := []string{"/static/jquery-3.5.1.min.js"} + JSSources := []string{ + "/static/jquery-3.6.4.min.js", + "/static/compiled/session.js", + } displayData := usersPageTemplateData{ - AuthUsername: authUser, - Title: "Keymaster Users", - Users: users, - JSSources: JSSources} + AuthUsername: authData.Username, + SessionExpires: authData.expires(), + Title: "Keymaster Users", + Users: users, + JSSources: JSSources} err = state.htmlTemplate.ExecuteTemplate(w, "usersPage", displayData) if err != nil { state.logger.Printf("Failed to execute %v", err) @@ -177,7 +182,7 @@ func (state *RuntimeState) deleteUserHandler(w http.ResponseWriter, func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter, r *http.Request) { - failure, authUser := state.sendFailureToClientIfNonAdmin(w, r) + failure, authData := state.sendFailureToClientIfNonAdmin(w, r) if failure { return } @@ -244,8 +249,9 @@ func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter, var fingerprint [4]byte copy(fingerprint[:], bootstrapOtpHash[:4]) displayData := newBootstrapOTPPPageTemplateData{ - Title: "New Bootstrap OTP Value", - AuthUsername: authUser, + Title: "New Bootstrap OTP Value", + AuthUsername: authData.Username, + SessionExpires: authData.expires(), //JSSources []string //ErrorMessage string Username: username, @@ -256,7 +262,7 @@ func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter, displayData.BootstrapOTPValue = bootstrapOtpValue } else { err := state.sendBootstrapOtpEmail(bootstrapOtpHash[:], - bootstrapOtpValue, duration, authUser, username) + bootstrapOtpValue, duration, authData.Username, username) if err != nil { state.logger.Printf("error sending email: %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, @@ -272,7 +278,7 @@ func (state *RuntimeState) generateBootstrapOTP(w http.ResponseWriter, } state.logger.Debugf(0, "%s: generated bootstrap OTP for: %s, duration: %s, hash: %x\n", - authUser, username, duration, bootstrapOtpHash) + authData.Username, username, duration, bootstrapOtpHash) returnAcceptType := getPreferredAcceptType(r) switch returnAcceptType { case "text/html": diff --git a/cmd/keymasterd/adminHandlers_test.go b/cmd/keymasterd/adminHandlers_test.go index d227f430..de502ab2 100644 --- a/cmd/keymasterd/adminHandlers_test.go +++ b/cmd/keymasterd/adminHandlers_test.go @@ -89,15 +89,15 @@ func TestAuthNoTLS(t *testing.T) { recorder := httptest.NewRecorder() w := &instrumentedwriter.LoggingWriter{ResponseWriter: recorder} req := httptest.NewRequest("GET", usersPath, nil) - errorSent, username := state.sendFailureToClientIfNonAdmin(w, req) + errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req) if errorSent { return } if recorder.Result().StatusCode != http.StatusUnauthorized { t.Errorf("unexpected status code: %d", recorder.Result().StatusCode) } - if username != "" { - t.Errorf("expected no username, got: %s", username) + if authData.Username != "" { + t.Errorf("expected no username, got: %s", authData.Username) } } @@ -112,15 +112,15 @@ func TestAuthCertAdminUser(t *testing.T) { req := httptest.NewRequest("GET", usersPath, nil) req.TLS, err = testMakeConnectionState("testdata/alice.pem", "testdata/KeymasterCA.pem") - errorSent, username := state.sendFailureToClientIfNonAdmin(w, req) + errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req) if errorSent { t.Fatal("error was sent") } if recorder.Result().StatusCode != http.StatusOK { t.Fatalf("unexpected status code: %d", recorder.Result().StatusCode) } - if username != "alice" { - t.Fatalf("unexpected username: alice, got: %s", username) + if authData.Username != "alice" { + t.Fatalf("unexpected username: alice, got: %s", authData.Username) } } @@ -135,15 +135,15 @@ func TestAuthCertPlainUser(t *testing.T) { req := httptest.NewRequest("GET", usersPath, nil) req.TLS, err = testMakeConnectionState("testdata/bob.pem", "testdata/KeymasterCA.pem") - errorSent, username := state.sendFailureToClientIfNonAdmin(w, req) + errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req) if !errorSent { t.Error("no error was sent") } if recorder.Result().StatusCode != http.StatusUnauthorized { t.Errorf("unexpected status code: %d", recorder.Result().StatusCode) } - if username != "" { - t.Errorf("expected no username, got: %s", username) + if authData != nil { + t.Errorf("expected no authData, got: %v", authData) } } @@ -158,15 +158,15 @@ func TestAuthCertFakeAdminUser(t *testing.T) { req := httptest.NewRequest("GET", usersPath, nil) req.TLS, err = testMakeConnectionState("testdata/alice-fake.pem", "testdata/AdminCA.pem") - errorSent, username := state.sendFailureToClientIfNonAdmin(w, req) + errorSent, authData := state.sendFailureToClientIfNonAdmin(w, req) if !errorSent { t.Error("no error was sent") } if recorder.Result().StatusCode != http.StatusUnauthorized { t.Errorf("unexpected status code: %d", recorder.Result().StatusCode) } - if username != "" { - t.Errorf("expected no username, got: %s", username) + if authData != nil { + t.Errorf("expected no authData, got: %v", authData) } } diff --git a/cmd/keymasterd/app.go b/cmd/keymasterd/app.go index 759c7bfa..0f471eac 100644 --- a/cmd/keymasterd/app.go +++ b/cmd/keymasterd/app.go @@ -29,6 +29,7 @@ import ( "time" "golang.org/x/net/context" + "golang.org/x/time/rate" "github.com/Cloud-Foundations/Dominator/lib/log/serverlogger" "github.com/Cloud-Foundations/Dominator/lib/logbuf" @@ -41,16 +42,18 @@ import ( "github.com/Cloud-Foundations/keymaster/keymasterd/admincache" "github.com/Cloud-Foundations/keymaster/keymasterd/eventnotifier" "github.com/Cloud-Foundations/keymaster/lib/authenticators/okta" - "github.com/Cloud-Foundations/keymaster/lib/authutil" "github.com/Cloud-Foundations/keymaster/lib/certgen" "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" + "github.com/Cloud-Foundations/keymaster/lib/paths" "github.com/Cloud-Foundations/keymaster/lib/pwauth" + "github.com/Cloud-Foundations/keymaster/lib/server/aws_identity_cert" "github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto" "github.com/Cloud-Foundations/keymaster/proto/eventmon" "github.com/Cloud-Foundations/tricorder/go/healthserver" "github.com/Cloud-Foundations/tricorder/go/tricorder" "github.com/Cloud-Foundations/tricorder/go/tricorder/units" "github.com/cloudflare/cfssl/revoke" + "github.com/duo-labs/webauthn/webauthn" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/tstranex/u2f" @@ -67,14 +70,21 @@ const ( AuthTypeOkta2FA AuthTypeBootstrapOTP AuthTypeKeymasterX509 + AuthTypeWebauthForCLI + AuthTypeFIDO2 ) -const AuthTypeAny = 0xFFFF +const ( + AuthTypeAny = 0xFFFF + maxCacheLifetime = time.Hour + maxWebauthForCliTokenLifetime = time.Hour * 24 * 366 +) type authInfo struct { + AuthType int ExpiresAt time.Time + IssuedAt time.Time Username string - AuthType int } type authInfoJWT struct { @@ -109,6 +119,13 @@ type u2fAuthData struct { Registration *u2f.Registration } +type webauthAuthData struct { + Enabled bool + CreatedAt time.Time + Name string + Credential webauthn.Credential +} + type totpAuthData struct { Enabled bool CreatedAt time.Time @@ -131,17 +148,25 @@ type userProfile struct { TOTPAuthData map[int64]*totpAuthData BootstrapOTP bootstrapOTPData UserHasRegistered2ndFactor bool + + WebauthnData map[int64]*webauthAuthData + WebauthnID uint64 // maybe more specific? + DisplayName string + Username string + WebauthnSessionData *webauthn.SessionData } type localUserData struct { - U2fAuthChallenge *u2f.Challenge - ExpiresAt time.Time + U2fAuthChallenge *u2f.Challenge + WebAuthnChallenge *webauthn.SessionData + ExpiresAt time.Time } type pendingAuth2Request struct { - ExpiresAt time.Time - state string - ctx context.Context + ctx context.Context + ExpiresAt time.Time + loginDestination string + state string } type pushPollTransaction struct { @@ -158,38 +183,40 @@ type totpRateLimitInfo struct { } type RuntimeState struct { - Config AppConfigFile - SSHCARawFileContent []byte - Signer crypto.Signer - Ed25519CAFileContent []byte - Ed25519Signer crypto.Signer - ClientCAPool *x509.CertPool - HostIdentity string - KerberosRealm *string - caCertDer []byte - certManager *certmanager.CertificateManager - vipPushCookie map[string]pushPollTransaction - localAuthData map[string]localUserData - SignerIsReady chan bool - oktaUsernameFilterRE *regexp.Regexp - Mutex sync.Mutex - gitDB *gitdb.UserInfo - pendingOauth2 map[string]pendingAuth2Request - storageRWMutex sync.RWMutex - db *sql.DB - dbType string - cacheDB *sql.DB - remoteDBQueryTimeout time.Duration - htmlTemplate *htmltemplate.Template - passwordChecker pwauth.PasswordAuthenticator - KeymasterPublicKeys []crypto.PublicKey - isAdminCache *admincache.Cache - emailManager configuredemail.EmailManager - textTemplates *texttemplate.Template - - totpLocalRateLimit map[string]totpRateLimitInfo - totpLocalTateLimitMutex sync.Mutex - logger log.DebugLogger + Config AppConfigFile + SSHCARawFileContent []byte + Signer crypto.Signer + Ed25519CAFileContent []byte + Ed25519Signer crypto.Signer + ClientCAPool *x509.CertPool + HostIdentity string + KerberosRealm *string + caCertDer []byte + certManager *certmanager.CertificateManager + vipPushCookie map[string]pushPollTransaction + localAuthData map[string]localUserData + SignerIsReady chan bool + oktaUsernameFilterRE *regexp.Regexp + passwordAttemptGlobalLimiter *rate.Limiter + Mutex sync.Mutex + gitDB *gitdb.UserInfo + pendingOauth2 map[string]pendingAuth2Request + storageRWMutex sync.RWMutex + db *sql.DB + dbType string + cacheDB *sql.DB + remoteDBQueryTimeout time.Duration + htmlTemplate *htmltemplate.Template + passwordChecker pwauth.PasswordAuthenticator + KeymasterPublicKeys []crypto.PublicKey + isAdminCache *admincache.Cache + emailManager configuredemail.EmailManager + textTemplates *texttemplate.Template + awsCertIssuer *aws_identity_cert.Issuer + webAuthn *webauthn.WebAuthn + totpLocalRateLimit map[string]totpRateLimitInfo + totpLocalTateLimitMutex sync.Mutex + logger log.DebugLogger } const redirectPath = "/auth/oauth2/callback" @@ -220,6 +247,13 @@ var ( }, []string{"client_type", "type", "result"}, ) + passwordRateLimitExceededCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "keymaster_password_rate_limit_exceeded_counter", + Help: "keymaster_password_rate_limit_exceeded_counter", + }, + []string{"username"}, + ) externalServiceDurationTotal = prometheus.NewHistogramVec( prometheus.HistogramOpts{ @@ -247,6 +281,16 @@ var ( eventNotifier *eventnotifier.EventNotifier ) +func cacheControlHandler(h http.Handler) http.Handler { + maxAgeSeconds := maxCacheLifetime / time.Second + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", + fmt.Sprintf("max-age=%d, public, must-revalidate, proxy-revalidate", + maxAgeSeconds)) + h.ServeHTTP(w, r) + }) +} + func metricLogAuthOperation(clientType string, authType string, success bool) { validStr := strconv.FormatBool(success) metricsMutex.Lock() @@ -376,20 +420,6 @@ func checkUserPassword(username string, password string, config AppConfigFile, metricLogAuthOperation(clientType, "password", valid) return valid, nil } - if config.Base.HtpasswdFilename != "" { - logger.Debugf(3, "I have htpasswed filename") - buffer, err := ioutil.ReadFile(config.Base.HtpasswdFilename) - if err != nil { - return false, err - } - valid, err := authutil.CheckHtpasswdUserPassword(username, password, - buffer) - if err != nil { - return false, err - } - metricLogAuthOperation(clientType, "password", valid) - return valid, nil - } metricLogAuthOperation(clientType, "password", false) return false, nil } @@ -416,15 +446,10 @@ func browserSupportsU2F(r *http.Request) bool { if strings.Contains(r.UserAgent(), "Presto/") { return true } - // Once FF support reaches main we can remove these silly checks. - if strings.Contains(r.UserAgent(), "Firefox/57") || - strings.Contains(r.UserAgent(), "Firefox/58") || - strings.Contains(r.UserAgent(), "Firefox/59") || - strings.Contains(r.UserAgent(), "Firefox/6") || - strings.Contains(r.UserAgent(), "Firefox/7") || - strings.Contains(r.UserAgent(), "Firefox/8") { + if strings.Contains(r.UserAgent(), "Firefox/") { return true } + logger.Debugf(3, "browser doest NOT support u2f") return false } @@ -447,10 +472,66 @@ func getClientType(r *http.Request) string { } } +// getOriginOrReferrer will return the value of the "Origin" header, or if +// empty, the Referrer (misspelled as "Referer" in the HTML standards). +func getOriginOrReferrer(r *http.Request) string { + if origin := r.Header.Get("Origin"); origin != "" { + return origin + } + return r.Referer() +} + +// getUser will return the "user" value from the request form. If the username +// contains invalid characters, the empty string is returned. +func getUserFromRequest(r *http.Request) string { + user := r.Form.Get("user") + if user == "" { + return "" + } + if m, _ := regexp.MatchString("^[-.a-zA-Z0-9_+]+$", user); !m { + return "" + } + return user +} + +func (ai *authInfo) expires() int64 { + if ai.ExpiresAt.IsZero() { + return 0 + } + return ai.ExpiresAt.Unix() +} + +func ensureHTMLSafeLoginDestination(loginDestination string) string { + if loginDestination == "" { + return profilePath + } + parsedLoginDestination, err := url.Parse(loginDestination) + if err != nil { + return profilePath + } + return parsedLoginDestination.String() + +} + +// checkPasswordAttemptLimit will check if the limit on password attempts has +// been reached. If the limit has been reached, an error response is written to +// w and an error message is returned. +func (state *RuntimeState) checkPasswordAttemptLimit(w http.ResponseWriter, + r *http.Request, username string) error { + if !state.passwordAttemptGlobalLimiter.Allow() { + state.writeFailureResponse(w, r, http.StatusTooManyRequests, + "Too many password attempts") + passwordRateLimitExceededCounter.WithLabelValues(username).Inc() + return fmt.Errorf("too many password attempts, host: %s user: %s", + r.RemoteAddr, username) + } + return nil +} + func (state *RuntimeState) writeHTML2FAAuthPage(w http.ResponseWriter, r *http.Request, loginDestination string, tryShowU2f bool, showBootstrapOTP bool) error { - JSSources := []string{"/static/jquery-3.5.1.min.js", "/static/u2f-api.js"} + JSSources := []string{"/static/jquery-3.6.4.min.js", "/static/u2f-api.js"} showU2F := browserSupportsU2F(r) && tryShowU2f if showU2F { JSSources = append(JSSources, "/static/webui-2fa-u2f.js") @@ -461,15 +542,17 @@ func (state *RuntimeState) writeHTML2FAAuthPage(w http.ResponseWriter, if state.Config.Okta.Enable2FA { JSSources = append(JSSources, "/static/webui-2fa-okta-push.js") } + safeLoginDestination := ensureHTMLSafeLoginDestination(loginDestination) displayData := secondFactorAuthTemplateData{ - Title: "Keymaster 2FA Auth", - JSSources: JSSources, - ShowBootstrapOTP: showBootstrapOTP, - ShowVIP: state.Config.SymantecVIP.Enabled, - ShowU2F: showU2F, - ShowTOTP: state.Config.Base.EnableLocalTOTP, - ShowOktaOTP: state.Config.Okta.Enable2FA, - LoginDestination: loginDestination} + Title: "Keymaster 2FA Auth", + JSSources: JSSources, + ShowBootstrapOTP: showBootstrapOTP, + ShowVIP: state.Config.SymantecVIP.Enabled, + ShowU2F: showU2F, + ShowTOTP: state.Config.Base.EnableLocalTOTP, + ShowOktaOTP: state.Config.Okta.Enable2FA, + LoginDestinationInput: htmltemplate.HTML(""), + } err := state.htmlTemplate.ExecuteTemplate(w, "secondFactorLoginPage", displayData) if err != nil { @@ -480,22 +563,31 @@ func (state *RuntimeState) writeHTML2FAAuthPage(w http.ResponseWriter, return nil } -func (state *RuntimeState) writeHTMLLoginPage(w http.ResponseWriter, r *http.Request, - loginDestination string, errorMessage string) error { - //footerText := state.getFooterText() +func (state *RuntimeState) writeHTMLLoginPage(w http.ResponseWriter, + r *http.Request, statusCode int, + defaultUsername, loginDestination, errorMessage string) { + showBasicAuth := true + if state.Config.Oauth2.Enabled && + (state.Config.Oauth2.ForceRedirect || state.passwordChecker == nil) { + showBasicAuth = false + } + w.WriteHeader(statusCode) + + safeLoginDestination := ensureHTMLSafeLoginDestination(loginDestination) displayData := loginPageTemplateData{ - Title: "Keymaster Login", - ShowOauth2: state.Config.Oauth2.Enabled, - HideStdLogin: state.Config.Base.HideStandardLogin, - LoginDestination: loginDestination, - ErrorMessage: errorMessage} + Title: "Keymaster Login", + DefaultUsername: defaultUsername, + ShowBasicAuth: showBasicAuth, + ShowOauth2: state.Config.Oauth2.Enabled, + LoginDestinationInput: htmltemplate.HTML(""), + ErrorMessage: errorMessage, + } err := state.htmlTemplate.ExecuteTemplate(w, "loginPage", displayData) if err != nil { logger.Printf("Failed to execute %v", err) http.Error(w, "error", http.StatusInternalServerError) - return err + return } - return nil } func (state *RuntimeState) writeFailureResponse(w http.ResponseWriter, @@ -517,7 +609,6 @@ func (state *RuntimeState) writeFailureResponse(w http.ResponseWriter, if code == http.StatusUnauthorized && returnAcceptType != "text/html" { w.Header().Set("WWW-Authenticate", `Basic realm="User Credentials"`) } - w.WriteHeader(code) switch code { case http.StatusUnauthorized: switch returnAcceptType { @@ -529,41 +620,54 @@ func (state *RuntimeState) writeFailureResponse(w http.ResponseWriter, } authCookie = cookie } - loginDestnation := profilePath - if r.URL.Path == idpOpenIDCAuthorizationPath { - loginDestnation = r.URL.String() + loginDestination := profilePath + switch r.URL.Path { + case idpOpenIDCAuthorizationPath, paths.ShowAuthToken, + paths.SendAuthDocument: + loginDestination = r.URL.String() } if r.Method == "POST" { /// assume it has been parsed... otherwise why are we here? if r.Form.Get("login_destination") != "" { - loginDestnation = getLoginDestination(r) + loginDestination = getLoginDestination(r) } } if authCookie == nil { // TODO: change by a message followed by an HTTP redirection - state.writeHTMLLoginPage(w, r, loginDestnation, message) + state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r), + loginDestination, message) return } info, err := state.getAuthInfoFromAuthJWT(authCookie.Value) if err != nil { - logger.Debugf(3, "write failure state, error from getinfo authInfoJWT") - state.writeHTMLLoginPage(w, r, loginDestnation, "") + logger.Debugf(3, + "write failure state, error from getinfo authInfoJWT") + state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r), + loginDestination, "") return } if info.ExpiresAt.Before(time.Now()) { - state.writeHTMLLoginPage(w, r, loginDestnation, "") + state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r), + loginDestination, "") return } if (info.AuthType & AuthTypePassword) == AuthTypePassword { - state.writeHTML2FAAuthPage(w, r, loginDestnation, true, false) + state.writeHTML2FAAuthPage(w, r, loginDestination, true, false) return } - state.writeHTMLLoginPage(w, r, loginDestnation, message) + if (info.AuthType & AuthTypeFederated) == AuthTypeFederated { + state.writeHTML2FAAuthPage(w, r, loginDestination, true, false) + return + } + state.writeHTMLLoginPage(w, r, code, getUserFromRequest(r), + loginDestination, message) return default: + w.WriteHeader(code) w.Write([]byte(publicErrorText)) } default: + w.WriteHeader(code) w.Write([]byte(publicErrorText)) } } @@ -594,15 +698,24 @@ func (state *RuntimeState) sendFailureToClientIfLocked(w http.ResponseWriter, r return false } -func (state *RuntimeState) setNewAuthCookie(w http.ResponseWriter, username string, authlevel int) (string, error) { - cookieVal, err := state.genNewSerializedAuthJWT(username, authlevel) +func (state *RuntimeState) setNewAuthCookie(w http.ResponseWriter, + username string, authlevel int) (string, error) { + cookieVal, err := state.genNewSerializedAuthJWT(username, authlevel, + maxAgeSecondsAuthCookie) if err != nil { logger.Println(err) return "", err } - expiration := time.Now().Add(time.Duration(maxAgeSecondsAuthCookie) * time.Second) - authCookie := http.Cookie{Name: authCookieName, Value: cookieVal, Expires: expiration, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteNoneMode} - + expiration := time.Now().Add(time.Duration(maxAgeSecondsAuthCookie) * + time.Second) + authCookie := http.Cookie{ + Name: authCookieName, + Value: cookieVal, + Expires: expiration, + Path: "/", HttpOnly: true, + Secure: true, + SameSite: http.SameSiteNoneMode, + } //use handler with original request. if w != nil { http.SetCookie(w, &authCookie) @@ -654,7 +767,7 @@ func (state *RuntimeState) isAutomationUser(username string) (bool, error) { return false, nil } -func (state *RuntimeState) getUsernameIfKeymasterSigned(VerifiedChains [][]*x509.Certificate) (string, error) { +func (state *RuntimeState) getUsernameIfKeymasterSigned(VerifiedChains [][]*x509.Certificate) (string, time.Time, error) { for _, chain := range VerifiedChains { if len(chain) < 2 { continue @@ -663,42 +776,42 @@ func (state *RuntimeState) getUsernameIfKeymasterSigned(VerifiedChains [][]*x509 //keymaster certs as signed directly certSignerPKFingerprint, err := getKeyFingerprint(chain[1].PublicKey) if err != nil { - return "", err + return "", time.Time{}, err } for _, key := range state.KeymasterPublicKeys { fp, err := getKeyFingerprint(key) if err != nil { - return "", err + return "", time.Time{}, err } if certSignerPKFingerprint == fp { - return username, nil + return username, chain[0].NotBefore, nil } } } - return "", nil + return "", time.Time{}, nil } -func (state *RuntimeState) getUsernameIfIPRestricted(VerifiedChains [][]*x509.Certificate, r *http.Request) (string, error, error) { +func (state *RuntimeState) getUsernameIfIPRestricted(VerifiedChains [][]*x509.Certificate, r *http.Request) (string, time.Time, error, error) { clientName := VerifiedChains[0][0].Subject.CommonName userCert := VerifiedChains[0][0] validIP, err := certgen.VerifyIPRestrictedX509CertIP(userCert, r.RemoteAddr) if err != nil { logger.Printf("Error verifying up restricted cert: %s", err) - return "", nil, err + return "", time.Time{}, nil, err } if !validIP { logger.Printf("Invalid IP for cert: %s is not valid for incoming connection", r.RemoteAddr) - return "", fmt.Errorf("Bad incoming ip addres"), nil + return "", time.Time{}, fmt.Errorf("Bad incoming ip addres"), nil } // Check if there are group restrictions on ok, err := state.isAutomationUser(clientName) if err != nil { - return "", nil, fmt.Errorf("checkAuth: Error checking user permissions for automation certs : %s", err) + return "", time.Time{}, nil, fmt.Errorf("checkAuth: Error checking user permissions for automation certs : %s", err) } if !ok { - return "", fmt.Errorf("Bad username for ip restricted cert"), nil + return "", time.Time{}, fmt.Errorf("Bad username for ip restricted cert"), nil } revoked, ok, err := revoke.VerifyCertificateError(userCert) @@ -709,29 +822,28 @@ func (state *RuntimeState) getUsernameIfIPRestricted(VerifiedChains [][]*x509.Ce if revoked == true && ok { logger.Printf("Cert is revoked") //state.writeFailureResponse(w, r, http.StatusUnauthorized, "revoked Cert") - return "", fmt.Errorf("revoked cert"), nil + return "", time.Time{}, fmt.Errorf("revoked cert"), nil } - return clientName, nil, nil + return clientName, time.Now(), nil, nil } // Inspired by http://stackoverflow.com/questions/21936332/idiomatic-way-of-requiring-http-basic-auth-in-go -func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, requiredAuthType int) (string, int, error) { +func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, requiredAuthType int) (*authInfo, error) { // Check csrf if r.Method != "GET" { - referer := r.Referer() + referer := getOriginOrReferrer(r) if len(referer) > 0 && len(r.Host) > 0 { state.logger.Debugf(3, "ref =%s, host=%s", referer, r.Host) refererURL, err := url.Parse(referer) if err != nil { - return "", AuthTypeNone, err + return nil, err } state.logger.Debugf(3, "refHost =%s, host=%s", refererURL.Host, r.Host) if refererURL.Host != r.Host { state.logger.Printf("CSRF detected.... rejecting with a 400") state.writeFailureResponse(w, r, http.StatusUnauthorized, "") - err := errors.New("CSRF detected... rejecting") - return "", AuthTypeNone, err + return nil, errors.New("CSRF detected... rejecting") } } } @@ -742,26 +854,34 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req "looks like authtype tls keymaster or ip cert, r.tls=%+v", r.TLS) if len(r.TLS.VerifiedChains) > 0 { if (requiredAuthType & AuthTypeKeymasterX509) != 0 { - tlsAuthUser, err := + tlsAuthUser, notBefore, err := state.getUsernameIfKeymasterSigned(r.TLS.VerifiedChains) if err == nil && tlsAuthUser != "" { - return tlsAuthUser, AuthTypeKeymasterX509, nil + return &authInfo{ + AuthType: AuthTypeKeymasterX509, + IssuedAt: notBefore, + Username: tlsAuthUser, + }, nil } } if (requiredAuthType & AuthTypeIPCertificate) != 0 { - clientName, userErr, err := + clientName, notBefore, userErr, err := state.getUsernameIfIPRestricted(r.TLS.VerifiedChains, r) if userErr != nil { state.writeFailureResponse(w, r, http.StatusForbidden, fmt.Sprintf("%s", userErr)) - return "", AuthTypeNone, userErr + return nil, userErr } if err != nil { state.writeFailureResponse(w, r, http.StatusInternalServerError, "") - return "", AuthTypeNone, err + return nil, err } - return clientName, AuthTypeIPCertificate, nil + return &authInfo{ + AuthType: AuthTypeIPCertificate, + IssuedAt: notBefore, + Username: clientName, + }, nil } } } @@ -777,15 +897,17 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req if (AuthTypePassword & requiredAuthType) == 0 { state.writeFailureResponse(w, r, http.StatusUnauthorized, "") err := errors.New("Insufficient Auth Level passwd") - return "", AuthTypeNone, err + return nil, err } //For now try also http basic (to be deprecated) user, pass, ok := r.BasicAuth() if !ok { state.writeFailureResponse(w, r, http.StatusUnauthorized, "") //toLoginOrBasicAuth(w, r) - err := errors.New("check_Auth, Invalid or no auth header") - return "", AuthTypeNone, err + return nil, errors.New("checkAuth, Invalid or no auth header") + } + if err := state.checkPasswordAttemptLimit(w, r, user); err != nil { + return nil, err } state.Mutex.Lock() config := state.Config @@ -795,15 +917,19 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req state.passwordChecker, r) if err != nil { state.writeFailureResponse(w, r, http.StatusInternalServerError, "") - return "", AuthTypeNone, err + return nil, err } if !valid { state.writeFailureResponse(w, r, http.StatusUnauthorized, "Invalid Username/Password") err := errors.New("Invalid Credentials") - return "", AuthTypeNone, err + return nil, err } - return user, AuthTypePassword, nil + return &authInfo{ + AuthType: AuthTypePassword, + IssuedAt: time.Now(), + Username: user, + }, nil } //Critical section info, err := state.getAuthInfoFromAuthJWT(authCookie.Value) @@ -811,22 +937,22 @@ func (state *RuntimeState) checkAuth(w http.ResponseWriter, r *http.Request, req //TODO check between internal and bad cookie error state.writeFailureResponse(w, r, http.StatusUnauthorized, "") err := errors.New("Invalid Cookie") - return "", AuthTypeNone, err + return nil, err } //check for expiration... if info.ExpiresAt.Before(time.Now()) { state.writeFailureResponse(w, r, http.StatusUnauthorized, "") err := errors.New("Expired Cookie") - return "", AuthTypeNone, err + return nil, err } if (info.AuthType & requiredAuthType) == 0 { state.logger.Debugf(1, "info.AuthType: %v, requiredAuthType: %v\n", info.AuthType, requiredAuthType) state.writeFailureResponse(w, r, http.StatusUnauthorized, "") err := errors.New("Insufficient Auth Level in critical cookie") - return "", info.AuthType, err + return nil, err } - return info.Username, info.AuthType, nil + return &info, nil } func (state *RuntimeState) getRequiredWebUIAuthLevel() int { @@ -895,10 +1021,9 @@ func (state *RuntimeState) publicPathHandler(w http.ResponseWriter, r *http.Requ switch target { case "loginForm": - w.WriteHeader(200) //fmt.Fprintf(w, "%s", loginFormText) setSecurityHeaders(w) - state.writeHTMLLoginPage(w, r, profilePath, "") + state.writeHTMLLoginPage(w, r, 200, "", profilePath, "") return case "x509ca": pemCert := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: state.caCertDer})) @@ -909,8 +1034,6 @@ func (state *RuntimeState) publicPathHandler(w http.ResponseWriter, r *http.Requ default: state.writeFailureResponse(w, r, http.StatusNotFound, "") return - //w.WriteHeader(200) - //fmt.Fprintf(w, "OK\n") } } @@ -922,11 +1045,18 @@ func (state *RuntimeState) userHasU2FTokens(username string) (bool, error) { if !ok { return false, nil } - registrations := getRegistrationArray(profile.U2fAuthData) - if len(registrations) < 1 { - return false, nil + for _, u2fRegistration := range profile.U2fAuthData { + if u2fRegistration.Enabled { + return true, nil + } + } - return true, nil + for _, webauthnRegustration := range profile.WebauthnData { + if webauthnRegustration.Enabled { + return true, nil + } + } + return false, nil } @@ -934,7 +1064,7 @@ const authCookieName = "auth_cookie" const vipTransactionCookieName = "vip_push_cookie" const maxAgeSecondsVIPCookie = 120 const randomStringEntropyBytes = 32 -const maxAgeSecondsAuthCookie = 57600 +const maxAgeSecondsAuthCookie = 16 * 3600 func genRandomString() (string, error) { size := randomStringEntropyBytes @@ -951,7 +1081,7 @@ func genRandomString() (string, error) { // // is interpreted as: use whatever protocol you think is OK func getLoginDestination(r *http.Request) string { loginDestination := profilePath - if r.Form.Get("login_destination") != "" { + if r.FormValue("login_destination") != "" { inboundLoginDestination := r.Form.Get("login_destination") if strings.HasPrefix(inboundLoginDestination, "/") && !strings.HasPrefix(inboundLoginDestination, "//") { @@ -989,7 +1119,7 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter, "Error parsing form") return } - logger.Debugf(2, "req =%+v", r) + logger.Debugf(4, "req =%+v", r) default: state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") return @@ -1006,6 +1136,11 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter, return } username = val[0] + // Since we are getting username from Form we need some minimal sanitization + // TODO: actually whitelist the username characters + escapedUsername := strings.Replace(username, "\n", "", -1) + escapedUsername = strings.Replace(escapedUsername, "\r", "", -1) + username = escapedUsername } //var password string if val, ok := r.Form["password"]; ok { @@ -1022,6 +1157,10 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter, return } } + if err := state.checkPasswordAttemptLimit(w, r, username); err != nil { + state.logger.Debugf(1, "%v", err) + return + } username = state.reprocessUsername(username) valid, err := checkUserPassword(username, password, state.Config, state.passwordChecker, r) @@ -1094,7 +1233,7 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter, certBackends = append(certBackends, proto.AuthTypeOkta2FA) } } - // logger.Printf("current backends=%+v", certBackends) + state.logger.Debugf(1, "current backends=%+v", certBackends) if len(certBackends) == 0 { certBackends = append(certBackends, proto.AuthTypeU2F) } @@ -1154,15 +1293,14 @@ func (state *RuntimeState) loginHandler(w http.ResponseWriter, return } -/// const logoutPath = "/api/v0/logout" -func (state *RuntimeState) logoutHandler(w http.ResponseWriter, r *http.Request) { +func (state *RuntimeState) logoutHandler(w http.ResponseWriter, + r *http.Request) { if state.sendFailureToClientIfLocked(w, r) { return } //TODO: check for CSRF (simple way: makeit post only) - // We first check for cookies var authCookie *http.Cookie for _, cookie := range r.Cookies() { @@ -1171,18 +1309,32 @@ func (state *RuntimeState) logoutHandler(w http.ResponseWriter, r *http.Request) } authCookie = cookie } - + var loginUser string if authCookie != nil { + info, err := state.getAuthInfoFromAuthJWT(authCookie.Value) + if err == nil { + loginUser = info.Username + } expiration := time.Unix(0, 0) - updatedAuthCookie := http.Cookie{Name: authCookieName, Value: "", Expires: expiration, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteNoneMode} + updatedAuthCookie := http.Cookie{ + Name: authCookieName, + Value: "", + Expires: expiration, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteNoneMode, + } http.SetCookie(w, &updatedAuthCookie) } //redirect to login - http.Redirect(w, r, "/", 302) + if loginUser == "" { + http.Redirect(w, r, "/", 302) + } else { + http.Redirect(w, r, fmt.Sprintf("/?user=%s", loginUser), 302) + } } -/// - func (state *RuntimeState) _IsAdminUser(user string) (bool, error) { for _, adminUser := range state.Config.Base.AdminUsers { if user == adminUser { @@ -1261,20 +1413,20 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request /* */ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) readOnlyMsg := "" if assumedUser == "" { - assumedUser = authUser - } else if !state.IsAdminUser(authUser) { + assumedUser = authData.Username + } else if !state.IsAdminUser(authData.Username) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return - } else if (loginLevel & AuthTypeU2F) == 0 { + } else if (authData.AuthType & AuthTypeU2F) == 0 { readOnlyMsg = "Admins must U2F authenticate to change the profile of others." } @@ -1289,11 +1441,13 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request if fromCache { readOnlyMsg = "The active keymaster is running disconnected from its DB backend. All token operations execpt for Authentication cannot proceed." } - - JSSources := []string{"/static/jquery-3.5.1.min.js"} + JSSources := []string{ + "/static/jquery-3.6.4.min.js", + "/static/compiled/session.js", + } showU2F := browserSupportsU2F(r) if showU2F { - JSSources = append(JSSources, "/static/u2f-api.js", "/static/keymaster-u2f.js") + JSSources = append(JSSources, "/static/u2f-api.js", "/static/keymaster-u2f.js", "/static/keymaster-webauthn.js") } // TODO: move deviceinfo mapping/sorting to its own function @@ -1306,6 +1460,18 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request Index: i} u2fdevices = append(u2fdevices, deviceData) } + // TODO: make some difference + // also add the webauthn devices... + for i, tokenInfo := range profile.WebauthnData { + deviceData := registeredU2FTokenDisplayInfo{ + DeviceData: fmt.Sprintf("webauthn-%s", tokenInfo.Credential.AttestationType), // TODO: replace by some other per cred data + Enabled: tokenInfo.Enabled, + Name: tokenInfo.Name, //Display name? + Index: i, + } + u2fdevices = append(u2fdevices, deviceData) + } + sort.Slice(u2fdevices, func(i, j int) bool { if u2fdevices[i].Name < u2fdevices[j].Name { return true @@ -1328,12 +1494,14 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request displayData := profilePageTemplateData{ Username: assumedUser, - AuthUsername: authUser, + AuthUsername: authData.Username, + SessionExpires: authData.expires(), Title: "Keymaster User Profile", ShowU2F: showU2F, JSSources: JSSources, ReadOnlyMsg: readOnlyMsg, - UsersLink: state.IsAdminUser(authUser), + UsersLink: state.IsAdminUser(authData.Username), + ShowLegacyRegister: state.passwordChecker != nil, RegisteredU2FToken: u2fdevices, ShowTOTP: showTOTP, RegisteredTOTPDevice: totpdevices, @@ -1354,7 +1522,6 @@ func (state *RuntimeState) profileHandler(w http.ResponseWriter, r *http.Request http.Error(w, "error", http.StatusInternalServerError) return } - //w.Write([]byte(indexHTML)) } const u2fTokenManagementPath = "/api/v0/manageU2FToken" @@ -1369,13 +1536,13 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http /* */ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, loginLevel, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { logger.Debugf(1, "%v", err) http.Error(w, "error", http.StatusInternalServerError) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) // TODO: ensure is a valid method (POST) err = r.ParseForm() if err != nil { @@ -1388,11 +1555,12 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http assumedUser := r.Form.Get("username") // Have admin rights = Must be admin + authenticated with U2F - hasAdminRights := state.IsAdminUserAndU2F(authUser, loginLevel) + hasAdminRights := state.IsAdminUserAndU2F(authData.Username, + authData.AuthType) // Check params - if !hasAdminRights && assumedUser != authUser { - logger.Printf("bad username authUser=%s requested=%s", authUser, r.Form.Get("username")) + if !hasAdminRights && assumedUser != authData.Username { + logger.Printf("bad username authUser=%s requested=%s", authData.Username, r.Form.Get("username")) state.writeFailureResponse(w, r, http.StatusUnauthorized, "") return } @@ -1420,8 +1588,8 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http // Todo: check for negative values _, ok := profile.U2fAuthData[tokenIndex] - if !ok { - //if tokenIndex >= len(profile.U2fAuthData) { + _, ok2 := profile.WebauthnData[tokenIndex] + if !ok && !ok2 { logger.Printf("bad index number") state.writeFailureResponse(w, r, http.StatusBadRequest, "bad index Value") return @@ -1437,13 +1605,30 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http state.writeFailureResponse(w, r, http.StatusBadRequest, "invalidtokenName") return } - profile.U2fAuthData[tokenIndex].Name = tokenName + if ok { + profile.U2fAuthData[tokenIndex].Name = tokenName + } else { + profile.WebauthnData[tokenIndex].Name = tokenName + } + case "Disable": - profile.U2fAuthData[tokenIndex].Enabled = false + if ok { + profile.U2fAuthData[tokenIndex].Enabled = false + } else { + profile.WebauthnData[tokenIndex].Enabled = false + } case "Enable": - profile.U2fAuthData[tokenIndex].Enabled = true + if ok { + profile.U2fAuthData[tokenIndex].Enabled = true + } else { + profile.WebauthnData[tokenIndex].Enabled = true + } case "Delete": - delete(profile.U2fAuthData, tokenIndex) + if ok { + delete(profile.U2fAuthData, tokenIndex) + } else { + delete(profile.WebauthnData, tokenIndex) + } default: state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid Operation") return @@ -1460,7 +1645,7 @@ func (state *RuntimeState) u2fTokenManagerHandler(w http.ResponseWriter, r *http returnAcceptType := getPreferredAcceptType(r) switch returnAcceptType { case "text/html": - http.Redirect(w, r, profileURI(authUser, assumedUser), 302) + http.Redirect(w, r, profileURI(authData.Username, assumedUser), 302) default: w.WriteHeader(200) fmt.Fprintf(w, "Success!") @@ -1489,8 +1674,21 @@ func (state *RuntimeState) defaultPathHandler(w http.ResponseWriter, r *http.Req //redirect to profile if r.URL.Path[:] == "/" { //landing page + if err := r.ParseForm(); err != nil { + logger.Println(err) + errCode := http.StatusInternalServerError + errMessage := "Error parsing form" + if strings.Contains(err.Error(), "invalid") { + errCode = http.StatusBadRequest + errMessage = "invalid query" + } + state.writeFailureResponse(w, r, errCode, + errMessage) + return + } if r.Method == "GET" && len(r.Cookies()) < 1 { - state.writeHTMLLoginPage(w, r, profilePath, "") + state.writeHTMLLoginPage(w, r, 200, getUserFromRequest(r), + profilePath, "") return } @@ -1524,6 +1722,7 @@ func Usage() { func init() { prometheus.MustRegister(certGenCounter) prometheus.MustRegister(authOperationCounter) + prometheus.MustRegister(passwordRateLimitExceededCounter) prometheus.MustRegister(externalServiceDurationTotal) prometheus.MustRegister(certDurationHistogram) tricorder.RegisterMetric( @@ -1603,40 +1802,73 @@ func main() { serviceMux.HandleFunc(addUserPath, runtimeState.addUserHandler) serviceMux.HandleFunc(deleteUserPath, runtimeState.deleteUserHandler) //TODO: should enable only if bootraptop is enabled - serviceMux.HandleFunc(generateBoostrapOTPPath, runtimeState.generateBootstrapOTP) - - serviceMux.HandleFunc(idpOpenIDCConfigurationDocumentPath, runtimeState.idpOpenIDCDiscoveryHandler) - serviceMux.HandleFunc(idpOpenIDCJWKSPath, runtimeState.idpOpenIDCJWKSHandler) - serviceMux.HandleFunc(idpOpenIDCAuthorizationPath, runtimeState.idpOpenIDCAuthorizationHandler) - serviceMux.HandleFunc(idpOpenIDCTokenPath, runtimeState.idpOpenIDCTokenHandler) - serviceMux.HandleFunc(idpOpenIDCUserinfoPath, runtimeState.idpOpenIDCUserinfoHandler) - - staticFilesPath := filepath.Join(runtimeState.Config.Base.SharedDataDirectory, "static_files") - serviceMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticFilesPath)))) - customWebResourcesPath := filepath.Join(runtimeState.Config.Base.SharedDataDirectory, "customization_data", "web_resources") + serviceMux.HandleFunc(generateBoostrapOTPPath, + runtimeState.generateBootstrapOTP) + + serviceMux.HandleFunc(idpOpenIDCConfigurationDocumentPath, + runtimeState.idpOpenIDCDiscoveryHandler) + serviceMux.HandleFunc(idpOpenIDCJWKSPath, + runtimeState.idpOpenIDCJWKSHandler) + serviceMux.HandleFunc(idpOpenIDCAuthorizationPath, + runtimeState.idpOpenIDCAuthorizationHandler) + serviceMux.HandleFunc(idpOpenIDCTokenPath, + runtimeState.idpOpenIDCTokenHandler) + serviceMux.HandleFunc(idpOpenIDCUserinfoPath, + runtimeState.idpOpenIDCUserinfoHandler) + + staticFilesPath := + filepath.Join(runtimeState.Config.Base.SharedDataDirectory, + "static_files") + serviceMux.Handle("/static/", cacheControlHandler( + http.StripPrefix("/static/", + http.FileServer(http.Dir(staticFilesPath))))) + serviceMux.Handle("/static/compiled/", cacheControlHandler( + http.StripPrefix("/static/compiled/", http.FileServer(AssetFile())))) + customWebResourcesPath := + filepath.Join(runtimeState.Config.Base.SharedDataDirectory, + "customization_data", "web_resources") if _, err = os.Stat(customWebResourcesPath); err == nil { - serviceMux.Handle("/custom_static/", http.StripPrefix("/custom_static/", http.FileServer(http.Dir(customWebResourcesPath)))) - } - serviceMux.HandleFunc(u2fRegustisterRequestPath, runtimeState.u2fRegisterRequest) - serviceMux.HandleFunc(u2fRegisterRequesponsePath, runtimeState.u2fRegisterResponse) + serviceMux.Handle("/custom_static/", cacheControlHandler( + http.StripPrefix("/custom_static/", + http.FileServer(http.Dir(customWebResourcesPath))))) + } + serviceMux.HandleFunc(u2fRegustisterRequestPath, + runtimeState.u2fRegisterRequest) + serviceMux.HandleFunc(u2fRegisterRequesponsePath, + runtimeState.u2fRegisterResponse) serviceMux.HandleFunc(u2fSignRequestPath, runtimeState.u2fSignRequest) serviceMux.HandleFunc(u2fSignResponsePath, runtimeState.u2fSignResponse) + serviceMux.HandleFunc(webAutnRegististerRequestPath, runtimeState.webauthnBeginRegistration) + serviceMux.HandleFunc(webAutnRegististerFinishPath, runtimeState.webauthnFinishRegistration) + serviceMux.HandleFunc(webAuthnAuthBeginPath, runtimeState.webauthnAuthLogin) + serviceMux.HandleFunc(webAuthnAuthFinishPath, runtimeState.webauthnAuthFinish) + serviceMux.HandleFunc(vipAuthPath, runtimeState.VIPAuthHandler) - serviceMux.HandleFunc(u2fTokenManagementPath, runtimeState.u2fTokenManagerHandler) - serviceMux.HandleFunc(oauth2LoginBeginPath, runtimeState.oauth2DoRedirectoToProviderHandler) + serviceMux.HandleFunc(u2fTokenManagementPath, + runtimeState.u2fTokenManagerHandler) + serviceMux.HandleFunc(oauth2LoginBeginPath, + runtimeState.oauth2DoRedirectoToProviderHandler) serviceMux.HandleFunc(redirectPath, runtimeState.oauth2RedirectPathHandler) - serviceMux.HandleFunc(clientConfHandlerPath, runtimeState.serveClientConfHandler) + serviceMux.HandleFunc(clientConfHandlerPath, + runtimeState.serveClientConfHandler) serviceMux.HandleFunc(vipPushStartPath, runtimeState.vipPushStartHandler) serviceMux.HandleFunc(vipPollCheckPath, runtimeState.VIPPollCheckHandler) serviceMux.HandleFunc(totpGeneratNewPath, runtimeState.GenerateNewTOTP) serviceMux.HandleFunc(totpValidateNewPath, runtimeState.validateNewTOTP) - serviceMux.HandleFunc(totpTokenManagementPath, runtimeState.totpTokenManagerHandler) + serviceMux.HandleFunc(totpTokenManagementPath, + runtimeState.totpTokenManagerHandler) serviceMux.HandleFunc(totpVerifyHandlerPath, runtimeState.verifyTOTPHandler) serviceMux.HandleFunc(totpAuthPath, runtimeState.TOTPAuthHandler) if runtimeState.Config.Okta.Domain != "" { serviceMux.HandleFunc(okta2FAauthPath, runtimeState.Okta2FAuthHandler) - serviceMux.HandleFunc(oktaPushStartPath, runtimeState.oktaPushStartHandler) - serviceMux.HandleFunc(oktaPollCheckPath, runtimeState.oktaPollCheckHandler) + serviceMux.HandleFunc(oktaPushStartPath, + runtimeState.oktaPushStartHandler) + serviceMux.HandleFunc(oktaPollCheckPath, + runtimeState.oktaPollCheckHandler) + } + if runtimeState.checkAwsRolesEnabled() { + serviceMux.HandleFunc(paths.RequestAwsRoleCertificatePath, + runtimeState.requestAwsRoleCertificateHandler) } // TODO(rgooch): Condition this on whether Bootstrap OTP is configured. // The inline calls to getRequiredWebUIAuthLevel() should be @@ -1644,6 +1876,14 @@ func main() { // bitfield test. serviceMux.HandleFunc(bootstrapOtpAuthPath, runtimeState.BootstrapOtpAuthHandler) + if runtimeState.Config.Base.WebauthTokenForCliLifetime > 0 { + serviceMux.HandleFunc(paths.SendAuthDocument, + runtimeState.SendAuthDocumentHandler) + serviceMux.HandleFunc(paths.ShowAuthToken, + runtimeState.ShowAuthTokenHandler) + serviceMux.HandleFunc(paths.VerifyAuthToken, + runtimeState.VerifyAuthTokenHandler) + } serviceMux.HandleFunc("/", runtimeState.defaultPathHandler) cfg := &tls.Config{ @@ -1660,7 +1900,6 @@ func main() { tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, }, } logFilterHandler := NewLogFilterHandler(http.DefaultServeMux, publicLogs, @@ -1676,7 +1915,7 @@ func main() { IdleTimeout: 120 * time.Second, } srpc.RegisterServerTlsConfig( - &tls.Config{ClientCAs: runtimeState.ClientCAPool}, + &tls.Config{ClientCAs: runtimeState.ClientCAPool, MinVersion: tls.VersionTLS12}, true) go func() { err := adminSrv.ListenAndServeTLS("", "") @@ -1728,7 +1967,6 @@ func main() { tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, }, } serviceSrv := &http.Server{ diff --git a/cmd/keymasterd/authToken.go b/cmd/keymasterd/authToken.go new file mode 100644 index 00000000..c9c3fd26 --- /dev/null +++ b/cmd/keymasterd/authToken.go @@ -0,0 +1,217 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" + "github.com/Cloud-Foundations/keymaster/lib/paths" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +func (state *RuntimeState) generateAuthJWT(username string) (string, error) { + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.RS256, + Key: state.Signer, + }, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + return "", err + } + issuer := state.idpGetIssuer() + now := time.Now().Unix() + authToken := authInfoJWT{ + Issuer: issuer, + Subject: username, + Audience: []string{issuer}, + Expiration: now + int64( + state.Config.Base.WebauthTokenForCliLifetime/time.Second), + NotBefore: now, + IssuedAt: now, + TokenType: "keymaster_webauth_for_cli_identity", + } + return jwt.Signed(signer).Claims(authToken).CompactSerialize() +} + +func (state *RuntimeState) SendAuthDocumentHandler(w http.ResponseWriter, + r *http.Request) { + state.logger.Debugln(1, "Entered SendAuthDocumentHandler()") + if state.sendFailureToClientIfLocked(w, r) { + return + } + if r.Method != "GET" && r.Method != "POST" { + state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") + return + } + if err := r.ParseForm(); err != nil { + state.logger.Println(err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "Error parsing form") + return + } + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + if err != nil { + state.logger.Debugln(1, err) + return + } + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + state.logger.Printf("%s requested authentication document export\n", + authData.Username) + // Fetch form/query data. + var portNumber uint64 + var token string + if val, ok := r.Form["port"]; !ok { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "No CLI port number provided") + state.logger.Printf("SendAuthDocument without port number") + return + } else { + if len(val) > 1 { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Just one port number allowed") + state.logger.Printf("SendAuthDocument with multiple port values") + return + } + if portNumber, err = strconv.ParseUint(val[0], 10, 16); err != nil { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Invalid port number") + state.logger.Printf("SendAuthDocument with invalid port number") + return + } + } + if val, ok := r.Form["token"]; !ok { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "No token provided") + state.logger.Printf("SendAuthDocument without token") + return + } else { + if len(val) > 1 { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Just one token allowed") + state.logger.Printf("SendAuthDocument with multiple token values") + return + } + token = val[0] + } + authInfo, err := state.getAuthInfoFromJWT(token, + "keymaster_webauth_for_cli_identity") + if err != nil { + state.writeFailureResponse(w, r, http.StatusBadRequest, "Bad token") + state.logger.Debugln(0, err) + return + } + if authInfo.Username != authData.Username { + state.writeFailureResponse(w, r, http.StatusBadRequest, "User mismatch") + state.logger.Printf( + "SendAuthDocumentHandler: authticated user: %s != token user: %s\n", + authData.Username, authInfo.Username) + return + } + if time.Until(authInfo.ExpiresAt) < 0 { + state.writeFailureResponse(w, r, http.StatusBadRequest, "Token expired") + state.logger.Debugln(0, "token expired") + return + } + // Generate a new cookie to send. + cookie, err := state.genNewSerializedAuthJWT(authInfo.Username, + AuthTypeWebauthForCLI, + int64(time.Until(authInfo.ExpiresAt)/time.Second)) + http.Redirect(w, r, + fmt.Sprintf("http://localhost:%d%s?auth_cookie=%s", + portNumber, paths.ReceiveAuthDocument, cookie), + http.StatusPermanentRedirect) +} + +func (state *RuntimeState) ShowAuthTokenHandler(w http.ResponseWriter, + r *http.Request) { + state.logger.Debugf(1, "Entered GetAuthTokenHandler(). URL: %v\n", r.URL) + if state.sendFailureToClientIfLocked(w, r) { + return + } + if r.Method != "GET" && r.Method != "POST" { + state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") + return + } + if err := r.ParseForm(); err != nil { + logger.Println(err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "Error parsing form") + return + } + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + if err != nil { + state.logger.Debugf(1, "%s", err) + return + } + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + displayData := authCodePageTemplateData{ + Title: "Keymaster CLI Token Display", + AuthUsername: authData.Username, + SessionExpires: authData.expires(), + } + token, err := state.generateAuthJWT(authData.Username) + if err != nil { + state.logger.Println(err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "Unable to generate token") + return + } + state.logger.Printf("generated webauth CLI token for: %s, lifetime: %s\n", + authData.Username, state.Config.Base.WebauthTokenForCliLifetime) + displayData.Token = token + err = state.htmlTemplate.ExecuteTemplate(w, "authTokenPage", displayData) + if err != nil { + logger.Printf("Failed to execute: %s\n", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "Error executing template") + } +} + +func (state *RuntimeState) VerifyAuthTokenHandler(w http.ResponseWriter, + r *http.Request) { + state.logger.Debugf(1, "Entered VerifyAuthTokenHandler(). URL: %v\n", r.URL) + if state.sendFailureToClientIfLocked(w, r) { + return + } + if r.Method != "GET" && r.Method != "POST" { + state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") + return + } + if err := r.ParseForm(); err != nil { + logger.Println(err) + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Error parsing form") + return + } + // Fetch form/query data. + var token string + if val, ok := r.Form["token"]; !ok { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "No token provided") + state.logger.Printf("VerifyAuthToken without token") + return + } else { + if len(val) > 1 { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Just one token allowed") + state.logger.Printf("VerifyAuthToken with multiple token values") + return + } + token = val[0] + } + authInfo, err := state.getAuthInfoFromJWT(token, + "keymaster_webauth_for_cli_identity") + if err != nil { + state.writeFailureResponse(w, r, http.StatusNotAcceptable, "Bad token") + state.logger.Debugln(0, err) + return + } + if time.Until(authInfo.ExpiresAt) < 0 { + state.writeFailureResponse(w, r, http.StatusGone, "Token expired") + state.logger.Debugln(0, "token expired") + return + } + w.Write([]byte("OK\n")) +} diff --git a/cmd/keymasterd/auth_oauth2.go b/cmd/keymasterd/auth_oauth2.go index 1e2709a4..4706f6f6 100644 --- a/cmd/keymasterd/auth_oauth2.go +++ b/cmd/keymasterd/auth_oauth2.go @@ -11,52 +11,62 @@ import ( "golang.org/x/net/context" ) -const maxAgeSecondsRedirCookie = 120 -const redirCookieName = "oauth2_redir" - -const oauth2LoginBeginPath = "/auth/oauth2/login" - -func (state *RuntimeState) oauth2DoRedirectoToProviderHandler(w http.ResponseWriter, r *http.Request) { +const ( + maxAgeSecondsRedirCookie = 120 + redirCookieName = "oauth2_redir" + oauth2LoginBeginPath = "/auth/oauth2/login" +) +func (state *RuntimeState) oauth2DoRedirectoToProviderHandler( + w http.ResponseWriter, r *http.Request) { if state.Config.Oauth2.Config == nil { - state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal") + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "error internal") logger.Println("asking for oauth2, but it is not defined") return } if !state.Config.Oauth2.Enabled { - state.writeFailureResponse(w, r, http.StatusBadRequest, "Oauth2 is not enabled in for this system") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Oauth2 is not enabled in for this system") logger.Println("asking for oauth2, but it is not enabled") return } cookieVal, err := genRandomString() if err != nil { - state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal") + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "error internal") logger.Println(err) return } - // we have to create new context and set redirector... - expiration := time.Now().Add(time.Duration(maxAgeSecondsRedirCookie) * time.Second) - + expiration := time.Now().Add(time.Duration(maxAgeSecondsRedirCookie) * + time.Second) stateString, err := genRandomString() if err != nil { - state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal") + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "error internal") logger.Println(err) return } - - cookie := http.Cookie{Name: redirCookieName, Value: cookieVal, - Expires: expiration, Path: "/", HttpOnly: true} + cookie := http.Cookie{ + Name: redirCookieName, + Value: cookieVal, + Expires: expiration, + Path: "/", + HttpOnly: true, + } http.SetCookie(w, &cookie) - pending := pendingAuth2Request{ - ExpiresAt: expiration, - state: stateString, - ctx: context.Background()} + ctx: context.Background(), + ExpiresAt: expiration, + loginDestination: getLoginDestination(r), + state: stateString, + } state.Mutex.Lock() state.pendingOauth2[cookieVal] = pending state.Mutex.Unlock() - http.Redirect(w, r, state.Config.Oauth2.Config.AuthCodeURL(stateString), http.StatusFound) + http.Redirect(w, r, state.Config.Oauth2.Config.AuthCodeURL(stateString), + http.StatusFound) } func httpGet(client *http.Client, url string) ([]byte, error) { @@ -64,45 +74,43 @@ func httpGet(client *http.Client, url string) ([]byte, error) { if err != nil { return nil, err } - defer r.Body.Close() - body, err := ioutil.ReadAll(r.Body) if err != nil { return nil, err } - if r.StatusCode >= 300 { return nil, fmt.Errorf(string(body)) } - logger.Debugf(8, "HTTP GET %s: %s %s", url, r.Status, string(body)) - return body, nil } -func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter, r *http.Request) { - +func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter, + r *http.Request) { if state.Config.Oauth2.Config == nil { - state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal") + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "error internal") logger.Println("asking for oauth2, but it is not defined") return } if !state.Config.Oauth2.Enabled { - state.writeFailureResponse(w, r, http.StatusBadRequest, "Oauth2 is not enabled in for this system") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Oauth2 is not enabled in for this system") logger.Println("asking for oauth2, but it is not enabled") return } - redirCookie, err := r.Cookie(redirCookieName) if err != nil { if err == http.ErrNoCookie { - state.writeFailureResponse(w, r, http.StatusBadRequest, "Missing setup cookie!") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Missing setup cookie!") logger.Println(err) return } // TODO: this is probably a user error? send back to oath2 login path? - state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal") + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "error internal") logger.Println(err) return } @@ -112,34 +120,33 @@ func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter, r *h state.Mutex.Unlock() if !ok { // clear cookie here!!!! - state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid setup cookie!") + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Invalid setup cookie!") logger.Println(err) return } - if r.URL.Query().Get("state") != pending.state { logger.Printf("state does not match") http.Error(w, "state did not match", http.StatusBadRequest) return } - //if Debug { - //logger.Printf("req : %+v", r) - //} - oauth2Token, err := state.Config.Oauth2.Config.Exchange(pending.ctx, r.URL.Query().Get("code")) + oauth2Token, err := state.Config.Oauth2.Config.Exchange(pending.ctx, + r.URL.Query().Get("code")) if err != nil { logger.Printf("failed to get token: ctx: %+v", pending.ctx) - http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) + http.Error(w, "Failed to exchange token: "+err.Error(), + http.StatusInternalServerError) return } client := state.Config.Oauth2.Config.Client(pending.ctx, oauth2Token) - //client.Get("...") body, err := httpGet(client, state.Config.Oauth2.UserinfoUrl) if err != nil { - logger.Printf("fail to fetch %s (%s) ", state.Config.Oauth2.UserinfoUrl, err.Error()) - http.Error(w, "Failed to get userinfo from url: "+err.Error(), http.StatusInternalServerError) + logger.Printf("fail to fetch %s (%s) ", state.Config.Oauth2.UserinfoUrl, + err.Error()) + http.Error(w, "Failed to get userinfo from url: "+err.Error(), + http.StatusInternalServerError) return } - var data struct { Name string `json:"name"` DisplayName string `json:"display_name"` @@ -148,46 +155,46 @@ func (state *RuntimeState) oauth2RedirectPathHandler(w http.ResponseWriter, r *h Email string `json:"email"` Attributes map[string][]string `json:"attributes"` } - logger.Debugf(3, "Userinfo body:'%s'", string(body)) err = json.Unmarshal(body, &data) if err != nil { logger.Printf("failed to unmarshall userinfo to fetch %s ", body) - http.Error(w, "Failed to get unmarshall userinfo: "+err.Error(), http.StatusInternalServerError) + http.Error(w, "Failed to get unmarshall userinfo: "+err.Error(), + http.StatusInternalServerError) return } - // The Name field could also be useful logger.Debugf(2, "%+v", data) - // Check if name is there.. - - // TODO: we need a more robust way to get the username and to add some filters. This - // mechanism is ok for 0.2 but not for 0.3. + // TODO: we need a more robust way to get the username and to add some + // filters. This mechanism is ok for 0.2 but not for 0.3. username := data.Login if username == "" { components := strings.Split(data.Email, "@") if len(components[0]) < 1 { - http.Error(w, "Email from userinfo is invalid: ", http.StatusInternalServerError) + http.Error(w, "Email from userinfo is invalid: ", + http.StatusInternalServerError) return } username = strings.ToLower(components[0]) } - - //Make new auth cookie + // Make new auth cookie _, err = state.setNewAuthCookie(w, username, AuthTypeFederated) if err != nil { - state.writeFailureResponse(w, r, http.StatusInternalServerError, "error internal") + state.writeFailureResponse(w, r, http.StatusInternalServerError, + "error internal") logger.Println(err) return } - - // delete peding cookie + // Delete pending cookie state.Mutex.Lock() delete(state.pendingOauth2, index) state.Mutex.Unlock() - eventNotifier.PublishWebLoginEvent(username) - //and redirect to profile page - http.Redirect(w, r, profilePath, 302) + loginDestination := pending.loginDestination + if loginDestination == "" { + // Nowhere else to go: go to profile page. + loginDestination = profilePath + } + http.Redirect(w, r, loginDestination, 302) } diff --git a/cmd/keymasterd/awsRole.go b/cmd/keymasterd/awsRole.go new file mode 100644 index 00000000..1066cb22 --- /dev/null +++ b/cmd/keymasterd/awsRole.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/x509" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/organizations" + "github.com/aws/aws-sdk-go-v2/service/sts" + + "github.com/Cloud-Foundations/keymaster/lib/certgen" + "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" +) + +const ( + awsAccountListInterval = time.Minute * 5 +) + +type assumeRoleCredentialsProvider struct { + credentials aws.Credentials + roleArn *string + stsClient *sts.Client +} + +func awsListAccounts(ctx context.Context, orgClient *organizations.Client) ( + map[string]struct{}, error) { + list := make(map[string]struct{}) + var nextToken *string + for { + output, err := orgClient.ListAccounts(ctx, + &organizations.ListAccountsInput{NextToken: nextToken}) + if err != nil { + return nil, err + } + for _, account := range output.Accounts { + list[*account.Id] = struct{}{} + } + if output.NextToken == nil { + break + } + nextToken = output.NextToken + } + return list, nil +} + +func (p *assumeRoleCredentialsProvider) Retrieve(ctx context.Context) ( + aws.Credentials, error) { + if time.Until(p.credentials.Expires) > time.Minute { + return p.credentials, nil + } + output, err := p.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ + RoleArn: p.roleArn, + RoleSessionName: aws.String("keymaster"), + }) + if err != nil { + return aws.Credentials{}, err + } + p.credentials = aws.Credentials{ + AccessKeyID: *output.Credentials.AccessKeyId, + CanExpire: true, + Expires: *output.Credentials.Expiration, + SecretAccessKey: *output.Credentials.SecretAccessKey, + SessionToken: *output.Credentials.SessionToken, + } + return p.credentials, nil +} + +func (state *RuntimeState) checkAwsRolesEnabled() bool { + if len(state.Config.AwsCerts.AllowedAccounts) > 0 { + return true + } + if state.Config.AwsCerts.ListAccountsRole != "" { + return true + } + return false +} + +func (state *RuntimeState) configureAwsRoles() error { + if len(state.Config.AwsCerts.AllowedAccounts) > 0 { + state.Config.AwsCerts.allowedAccounts = + make(map[string]struct{}) + for _, id := range state.Config.AwsCerts.AllowedAccounts { + if id != "*" { + if _, err := strconv.ParseUint(id, 10, 64); err != nil { + return fmt.Errorf("accountID: %s is not a number", id) + } + } + state.Config.AwsCerts.allowedAccounts[id] = struct{}{} + } + } + if state.Config.AwsCerts.ListAccountsRole != "" { + ctx := context.TODO() + awsConfig, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithEC2IMDSRegion()) + if err != nil { + return err + } + credsProvider := &assumeRoleCredentialsProvider{ + roleArn: aws.String(state.Config.AwsCerts.ListAccountsRole), + stsClient: sts.NewFromConfig(awsConfig), + } + awsConfig, err = awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithEC2IMDSRegion(), + awsconfig.WithCredentialsProvider(credsProvider)) + if err != nil { + return err + } + orgClient := organizations.NewFromConfig(awsConfig) + state.Config.AwsCerts.organisationAccounts, err = + awsListAccounts(ctx, orgClient) + if err != nil { + return err + } + state.logger.Printf("Discovered %d accounts in AWS Organisation\n", + len(state.Config.AwsCerts.organisationAccounts)) + go state.refreshAwsAccounts(ctx, orgClient) + } + return nil +} + +func (state *RuntimeState) checkAwsAccountAllowed(accountId string) bool { + if _, ok := state.Config.AwsCerts.allowedAccounts[accountId]; ok { + return true + } + if _, ok := state.Config.AwsCerts.organisationAccounts[accountId]; ok { + return true + } + if _, ok := state.Config.AwsCerts.allowedAccounts["*"]; ok { + return true + } + return false +} + +func (state *RuntimeState) refreshAwsAccounts(ctx context.Context, + orgClient *organizations.Client) { + for { + time.Sleep(awsAccountListInterval) + if list, err := awsListAccounts(ctx, orgClient); err != nil { + state.logger.Println(err) + } else { + oldLength := len(state.Config.AwsCerts.organisationAccounts) + state.Config.AwsCerts.organisationAccounts = list + if len(list) != oldLength { + state.logger.Printf( + "Discovered %d accounts in AWS Organisation, was %d\n", + len(list), oldLength) + } + } + } +} + +func (state *RuntimeState) requestAwsRoleCertificateHandler( + w http.ResponseWriter, r *http.Request) { + state.logger.Debugln(1, "Entered requestAwsRoleCertificateHandler()") + if state.sendFailureToClientIfLocked(w, r) { + return + } + cert := state.awsCertIssuer.RequestHandler(w, r) + if cert != nil { + w.(*instrumentedwriter.LoggingWriter).SetUsername( + cert.Subject.CommonName) + } +} + +// Returns signed certificate DER. +func (state *RuntimeState) generateRoleCert(template *x509.Certificate, + publicKey interface{}) ([]byte, error) { + strong, err := certgen.ValidatePublicKeyStrength(publicKey) + if err != nil { + return nil, err + } + if !strong { + return nil, fmt.Errorf("key too weak") + } + caCert, err := x509.ParseCertificate(state.caCertDer) + if err != nil { + return nil, err + } + certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, + publicKey, state.Signer) + if err != nil { + return nil, err + } + metricLogCertDuration("x509", "granted", + float64(time.Until(template.NotAfter).Seconds())) + go func(username string, certType string) { + metricsMutex.Lock() + defer metricsMutex.Unlock() + certGenCounter.WithLabelValues(username, certType).Inc() + }(template.Subject.CommonName, "x509") + return certDER, nil +} diff --git a/cmd/keymasterd/binData.go b/cmd/keymasterd/binData.go new file mode 100644 index 00000000..bcada58c --- /dev/null +++ b/cmd/keymasterd/binData.go @@ -0,0 +1,332 @@ +// Code generated for package main by go-bindata DO NOT EDIT. (@generated) +// sources: +// cmd/keymasterd/data/session.js +package main + +import ( + "bytes" + "compress/gzip" + "fmt" + "net/http" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" +) + +func bindataRead(data []byte, name string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, gz) + clErr := gz.Close() + + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + if clErr != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +type asset struct { + bytes []byte + info os.FileInfo +} + +type bindataFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time +} + +// Name return file name +func (fi bindataFileInfo) Name() string { + return fi.name +} + +// Size return file size +func (fi bindataFileInfo) Size() int64 { + return fi.size +} + +// Mode return file mode +func (fi bindataFileInfo) Mode() os.FileMode { + return fi.mode +} + +// Mode return file modify time +func (fi bindataFileInfo) ModTime() time.Time { + return fi.modTime +} + +// IsDir return file whether a directory +func (fi bindataFileInfo) IsDir() bool { + return fi.mode&os.ModeDir != 0 +} + +// Sys return file is sys mode +func (fi bindataFileInfo) Sys() interface{} { + return nil +} + + +type assetFile struct { + *bytes.Reader + name string + childInfos []os.FileInfo + childInfoOffset int +} + +type assetOperator struct{} + +// Open implement http.FileSystem interface +func (f *assetOperator) Open(name string) (http.File, error) { + var err error + if len(name) > 0 && name[0] == '/' { + name = name[1:] + } + content, err := Asset(name) + if err == nil { + return &assetFile{name: name, Reader: bytes.NewReader(content)}, nil + } + children, err := AssetDir(name) + if err == nil { + childInfos := make([]os.FileInfo, 0, len(children)) + for _, child := range children { + childPath := filepath.Join(name, child) + info, errInfo := AssetInfo(filepath.Join(name, child)) + if errInfo == nil { + childInfos = append(childInfos, info) + } else { + childInfos = append(childInfos, newDirFileInfo(childPath)) + } + } + return &assetFile{name: name, childInfos: childInfos}, nil + } else { + // If the error is not found, return an error that will + // result in a 404 error. Otherwise the server returns + // a 500 error for files not found. + if strings.Contains(err.Error(), "not found") { + return nil, os.ErrNotExist + } + return nil, err + } +} + +// Close no need do anything +func (f *assetFile) Close() error { + return nil +} + +// Readdir read dir's children file info +func (f *assetFile) Readdir(count int) ([]os.FileInfo, error) { + if len(f.childInfos) == 0 { + return nil, os.ErrNotExist + } + if count <= 0 { + return f.childInfos, nil + } + if f.childInfoOffset+count > len(f.childInfos) { + count = len(f.childInfos) - f.childInfoOffset + } + offset := f.childInfoOffset + f.childInfoOffset += count + return f.childInfos[offset : offset+count], nil +} + +// Stat read file info from asset item +func (f *assetFile) Stat() (os.FileInfo, error) { + if len(f.childInfos) != 0 { + return newDirFileInfo(f.name), nil + } + return AssetInfo(f.name) +} + +// newDirFileInfo return default dir file info +func newDirFileInfo(name string) os.FileInfo { + return &bindataFileInfo{ + name: name, + size: 0, + mode: os.FileMode(2147484068), // equal os.FileMode(0644)|os.ModeDir + modTime: time.Time{}} +} + +// AssetFile return a http.FileSystem instance that data backend by asset +func AssetFile() http.FileSystem { + return &assetOperator{} +} + +var _sessionJs = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x74\x90\x41\x4f\x02\x31\x10\x85\xef\xfd\x15\x93\x5e\x68\x15\x37\xeb\xd5\x0d\x07\x95\x3d\x90\x68\x3c\x80\x3f\xa0\xd2\x11\x9a\xb0\x53\xd2\xbe\x15\x8c\xe1\xbf\x9b\xc2\x66\x0d\x07\x6f\xcd\x74\xde\xf7\xde\xbc\xcf\x5e\xd6\x08\x51\x28\x6f\xe3\x61\xc9\x39\x87\x28\xed\x71\x1f\x92\x2b\x53\x63\xe9\x47\x11\x11\x79\x07\xa6\x19\xed\x5d\xca\xbc\x10\x18\x1f\xd7\x7d\xc7\x82\x6a\xc3\x68\x77\x5c\x9e\x4f\xdf\x0b\x6f\x74\xbe\x20\xee\xbc\x83\xd3\xb6\x7c\x3f\x02\x29\x7c\xf4\x60\xa3\x0b\x45\x5b\xdb\x9c\x91\x3c\xba\xac\x42\x57\xe0\xc2\x07\x9a\x3b\xb0\x29\x6b\x37\xf7\x75\x5d\x0f\x9b\x08\xd8\xf1\x8a\x8f\xa0\x19\xe9\x21\xe3\x45\xce\xf9\x81\xf4\xed\x35\xa9\x42\x5c\x22\x05\xd9\x98\x41\xee\x7a\x6c\xdf\x33\x27\x71\x1d\x57\xf9\x2a\xd0\x99\xac\xa7\x7f\x0e\xb6\x51\x27\xa5\xc6\xeb\x9c\xf7\xed\x17\x0b\x5e\x42\x06\x0b\x27\x33\x99\xbf\xbd\x3e\x47\x41\x99\x45\xe7\xd9\x4f\xa6\x34\x56\x38\xb6\xf5\x4f\x97\x8d\x3a\xd9\x46\xfd\x06\x00\x00\xff\xff\xc9\x75\x60\x9a\x72\x01\x00\x00") + +func sessionJsBytes() ([]byte, error) { + return bindataRead( + _sessionJs, + "session.js", + ) +} + +func sessionJs() (*asset, error) { + bytes, err := sessionJsBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "session.js", size: 370, mode: os.FileMode(420), modTime: time.Unix(1634278980, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) + } + return a.bytes, nil + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// MustAsset is like Asset but panics when Asset would return an error. +// It simplifies safe initialization of global variables. +func MustAsset(name string) []byte { + a, err := Asset(name) + if err != nil { + panic("asset: Asset(" + name + "): " + err.Error()) + } + + return a +} + +// AssetInfo loads and returns the asset info for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func AssetInfo(name string) (os.FileInfo, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) + } + return a.info, nil + } + return nil, fmt.Errorf("AssetInfo %s not found", name) +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() (*asset, error){ + "session.js": sessionJs, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"} +// AssetDir("data/img") would return []string{"a.png", "b.png"} +// AssetDir("foo.txt") and AssetDir("notexist") would return an error +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + cannonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(cannonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for childName := range node.Children { + rv = append(rv, childName) + } + return rv, nil +} + +type bintree struct { + Func func() (*asset, error) + Children map[string]*bintree +} + +var _bintree = &bintree{nil, map[string]*bintree{ + "session.js": &bintree{sessionJs, map[string]*bintree{}}, +}} + +// RestoreAsset restores an asset under the given directory +func RestoreAsset(dir, name string) error { + data, err := Asset(name) + if err != nil { + return err + } + info, err := AssetInfo(name) + if err != nil { + return err + } + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + if err != nil { + return err + } + err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) + if err != nil { + return err + } + err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) + if err != nil { + return err + } + return nil +} + +// RestoreAssets restores an asset under the given directory recursively +func RestoreAssets(dir, name string) error { + children, err := AssetDir(name) + // File + if err != nil { + return RestoreAsset(dir, name) + } + // Dir + for _, child := range children { + err = RestoreAssets(dir, filepath.Join(name, child)) + if err != nil { + return err + } + } + return nil +} + +func _filePath(dir, name string) string { + cannonicalName := strings.Replace(name, "\\", "/", -1) + return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) +} diff --git a/cmd/keymasterd/certgen.go b/cmd/keymasterd/certgen.go index 52bc94fc..5b1d8b4e 100644 --- a/cmd/keymasterd/certgen.go +++ b/cmd/keymasterd/certgen.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "os" "regexp" "strings" "time" @@ -15,11 +16,15 @@ import ( "github.com/Cloud-Foundations/keymaster/lib/authutil" "github.com/Cloud-Foundations/keymaster/lib/certgen" "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" + "github.com/Cloud-Foundations/keymaster/lib/util" "github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto" "golang.org/x/crypto/ssh" ) -const certgenPath = "/certgen/" +const ( + certgenPath = "/certgen/" + maxCertificateLifetime = time.Hour * 24 +) func prependGroups(groups []string, prefix string) []string { if prefix == "" { @@ -53,13 +58,15 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request /* */ // TODO(camilo_viecco1): reorder checks so that simple checks are done before checking user creds - authUser, authLevel, err := state.checkAuth(w, r, AuthTypeAny) + authData, err := state.checkAuth(w, r, AuthTypeAny) if err != nil { logger.Debugf(1, "%v", err) return } - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) - logger.Debugf(1, "Certgen, authenticated at level=%x, username=`%s`", authLevel, authUser) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) + logger.Debugf(1, + "Certgen, authenticated at level=%x, username=`%s`, expires=%s", + authData.AuthType, authData.Username, authData.ExpiresAt) sufficientAuthLevel := false // We should do an intersection operation here @@ -67,40 +74,53 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request if certPref == proto.AuthTypePassword { sufficientAuthLevel = true } - if certPref == proto.AuthTypeU2F && ((authLevel & AuthTypeU2F) == AuthTypeU2F) { + if certPref == proto.AuthTypeU2F && + ((authData.AuthType & AuthTypeU2F) == AuthTypeU2F) { + sufficientAuthLevel = true + } + if certPref == proto.AuthTypeTOTP && + ((authData.AuthType & AuthTypeTOTP) == AuthTypeTOTP) { sufficientAuthLevel = true } - if certPref == proto.AuthTypeTOTP && ((authLevel & AuthTypeTOTP) == AuthTypeTOTP) { + if certPref == proto.AuthTypeSymantecVIP && + ((authData.AuthType & AuthTypeSymantecVIP) == AuthTypeSymantecVIP) { sufficientAuthLevel = true } - if certPref == proto.AuthTypeSymantecVIP && ((authLevel & AuthTypeSymantecVIP) == AuthTypeSymantecVIP) { + if certPref == proto.AuthTypeIPCertificate && + ((authData.AuthType & AuthTypeIPCertificate) == AuthTypeIPCertificate) { sufficientAuthLevel = true } - if certPref == proto.AuthTypeIPCertificate && ((authLevel & AuthTypeIPCertificate) == AuthTypeIPCertificate) { + if certPref == proto.AuthTypeOkta2FA && + ((authData.AuthType & AuthTypeOkta2FA) == AuthTypeOkta2FA) { sufficientAuthLevel = true } - if certPref == proto.AuthTypeOkta2FA && ((authLevel & AuthTypeOkta2FA) == AuthTypeOkta2FA) { + if certPref == proto.AuthTypeWebauthForCLI && + ((authData.AuthType & AuthTypeWebauthForCLI) == + AuthTypeWebauthForCLI) { sufficientAuthLevel = true } } // if you have u2f you can always get the cert - if (authLevel & AuthTypeU2F) == AuthTypeU2F { + if (authData.AuthType & AuthTypeU2F) == AuthTypeU2F { sufficientAuthLevel = true } if !sufficientAuthLevel { logger.Printf("Not enough auth level for getting certs") - state.writeFailureResponse(w, r, http.StatusBadRequest, "Not enough auth level for getting certs") + state.writeFailureResponse(w, r, http.StatusUnauthorized, + "Not enough auth level for getting certs") return } targetUser := r.URL.Path[len(certgenPath):] - if authUser != targetUser { + if authData.Username != targetUser { state.writeFailureResponse(w, r, http.StatusForbidden, "") - logger.Printf("User %s asking for creds for %s", authUser, targetUser) + logger.Debugf(1, "User %s asking for creds for %s", + authData.Username, targetUser) return } - logger.Debugf(3, "auth succedded for %s", authUser) + targetUser = authData.Username + logger.Debugf(3, "auth succedded for %s", authData.Username) if r.Method != "POST" { state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") @@ -114,7 +134,7 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request state.writeFailureResponse(w, r, http.StatusBadRequest, "Error parsing form") return } - duration := time.Duration(24 * time.Hour) + duration := maxCertificateLifetime if formDuration, ok := r.Form["duration"]; ok { stringDuration := formDuration[0] newDuration, err := time.ParseDuration(stringDuration) @@ -131,12 +151,16 @@ func (state *RuntimeState) certGenHandler(w http.ResponseWriter, r *http.Request } duration = newDuration } + maxDuration := time.Until(authData.IssuedAt.Add(maxCertificateLifetime)) + if duration > maxDuration { + duration = maxDuration + } certType := "ssh" if val, ok := r.Form["type"]; ok { certType = val[0] } - logger.Printf("cert type =%s", certType) + logger.Debugf(1, "cert type =%s", certType) switch certType { case "ssh": @@ -183,6 +207,24 @@ func getValidSSHPublicKey(userPubKey string) (ssh.PublicKey, error, error) { return userSSH, nil, nil } +func (state *RuntimeState) expandSSHExtensions(username string) (map[string]string, error) { + mapper := func(placeholderName string) string { + switch placeholderName { + case "USERNAME": + return username + } + return "" + } + userExtensions := make(map[string]string) + for _, extension := range state.Config.Base.SSHCertConfig.Extensions { + key := os.Expand(extension.Key, mapper) + value := os.Expand(extension.Value, mapper) + userExtensions[key] = value + } + + return userExtensions, nil +} + func (state *RuntimeState) postAuthSSHCertHandler( w http.ResponseWriter, r *http.Request, targetUser string, duration time.Duration) { @@ -207,7 +249,7 @@ func (state *RuntimeState) postAuthSSHCertHandler( sshUserPublicKey, userErr, err := getValidSSHPublicKey(userPubKey) if err != nil { - logger.Println(err) + logger.Debugf(1, "postAuthSSHCertHandler: getValidSSHPublicKey err=%s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "") return } @@ -234,8 +276,13 @@ func (state *RuntimeState) postAuthSSHCertHandler( logger.Printf("Signer failed to load") return } - - certString, cert, err = certgen.GenSSHCertFileString(targetUser, userPubKey, signer, state.HostIdentity, duration) + extensions, err := state.expandSSHExtensions(targetUser) + if err != nil { + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + logger.Printf("Extensions Failed to expand") + return + } + certString, cert, err = certgen.GenSSHCertFileString(targetUser, userPubKey, signer, state.HostIdentity, duration, extensions) if err != nil { state.writeFailureResponse(w, r, http.StatusInternalServerError, "") logger.Printf("signUserPubkey Err") @@ -244,11 +291,13 @@ func (state *RuntimeState) postAuthSSHCertHandler( eventNotifier.PublishSSH(cert.Marshal()) metricLogCertDuration("ssh", "granted", float64(duration.Seconds())) + clientIpAddress := util.GetRequestRealIp(r) w.Header().Set("Content-Disposition", "attachment; filename=\""+cert.Type()+"-cert.pub\"") w.WriteHeader(200) fmt.Fprintf(w, "%s", certString) - logger.Printf("Generated SSH Certifcate for %s. Serial:%d", targetUser, cert.Serial) + logger.Printf("Generated SSH Certificate for %s (from %s) . Serial: %d", + targetUser, clientIpAddress, cert.Serial) go func(username string, certType string) { metricsMutex.Lock() defer metricsMutex.Unlock() @@ -311,6 +360,14 @@ func (state *RuntimeState) getUserGroups(username string) ([]string, error) { return nil, nil } +func (state *RuntimeState) getServiceMethods(username string) ( + []string, error) { + if state.gitDB == nil { + return nil, nil + } + return state.gitDB.GetUserServiceMethods(username) +} + func (state *RuntimeState) postAuthX509CertHandler( w http.ResponseWriter, r *http.Request, targetUser string, keySigner crypto.Signer, duration time.Duration, @@ -324,7 +381,7 @@ func (state *RuntimeState) postAuthX509CertHandler( logger.Debugf(2, "Groups needed for cert") userGroups, err = state.getUserGroups(targetUser) if err != nil { - logger.Println(err) + logger.Printf("Cannot get user groups: %s\n", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "") return } @@ -336,73 +393,88 @@ func (state *RuntimeState) postAuthX509CertHandler( if kubernetesHack { organizations = userGroups } + serviceMethods, err := state.getServiceMethods(targetUser) + if err != nil { + logger.Println(err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + return + } var cert string - switch r.Method { - case "POST": - file, _, err := r.FormFile("pubkeyfile") - if err != nil { - logger.Println(err) - state.writeFailureResponse(w, r, http.StatusBadRequest, - "Missing public key file") - return - } - defer file.Close() - buf := new(bytes.Buffer) - buf.ReadFrom(file) - - block, _ := pem.Decode(buf.Bytes()) - if block == nil || block.Type != "PUBLIC KEY" { - state.writeFailureResponse(w, r, http.StatusBadRequest, - "Invalid File, Unable to decode pem") - logger.Printf("invalid file, unable to decode pem") - return - } - userPub, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - state.writeFailureResponse(w, r, http.StatusBadRequest, - "Cannot parse public key") - logger.Printf("Cannot parse public key") - return - } - validKey, err := certgen.ValidatePublicKeyStrength(userPub) - if err != nil { - logger.Println(err) - state.writeFailureResponse(w, r, http.StatusInternalServerError, "") - return - } - if !validKey { - state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid File, Check Key strength/key type") - logger.Printf("Invalid File, Check Key strength/key type") - return - } - caCert, err := x509.ParseCertificate(state.caCertDer) - if err != nil { - state.writeFailureResponse(w, r, http.StatusInternalServerError, "") - logger.Printf("Cannot parse CA Der data") - return - } - derCert, err := certgen.GenUserX509Cert(targetUser, userPub, caCert, - keySigner, state.KerberosRealm, duration, groups, organizations) - if err != nil { - state.writeFailureResponse(w, r, http.StatusInternalServerError, "") - logger.Printf("Cannot Generate x509cert") - return - } - eventNotifier.PublishX509(derCert) - cert = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", - Bytes: derCert})) - - default: + if r.Method != "POST" { state.writeFailureResponse(w, r, http.StatusMethodNotAllowed, "") return + } + + file, _, err := r.FormFile("pubkeyfile") + if err != nil { + logger.Printf("Cannot get public key from form: %s\n", err) + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Missing public key file") + return + } + defer file.Close() + buf := new(bytes.Buffer) + buf.ReadFrom(file) + block, _ := pem.Decode(buf.Bytes()) + if block == nil || block.Type != "PUBLIC KEY" { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Invalid File, Unable to decode pem") + logger.Printf("invalid file, unable to decode pem") + return + } + userPub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + state.writeFailureResponse(w, r, http.StatusBadRequest, + "Cannot parse public key") + logger.Printf("Cannot parse public key: %s\n", err) + return } + validKey, err := certgen.ValidatePublicKeyStrength(userPub) + if err != nil { + logger.Printf("Cannot validate public key strength: %s\n", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + return + } + if !validKey { + state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid File, Check Key strength/key type") + logger.Printf("Invalid File, Check Key strength/key type") + return + } + caCert, err := x509.ParseCertificate(state.caCertDer) + if err != nil { + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + logger.Printf("Cannot parse CA Der: %s\n data", err) + return + } + derCert, err := certgen.GenUserX509Cert(targetUser, userPub, caCert, + keySigner, state.KerberosRealm, duration, groups, organizations, + serviceMethods, logger) + if err != nil { + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + logger.Printf("Cannot Generate x509cert: %s\n", err) + return + } + parsedCert, err := x509.ParseCertificate(derCert) + if err != nil { + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + logger.Printf("Cannot Parse Generated x509cert: %s\n", err) + return + } + + eventNotifier.PublishX509(derCert) + cert = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", + Bytes: derCert})) + metricLogCertDuration("x509", "granted", float64(duration.Seconds())) + clientIpAddress := util.GetRequestRealIp(r) + w.Header().Set("Content-Disposition", `attachment; filename="userCert.pem"`) w.WriteHeader(200) fmt.Fprintf(w, "%s", cert) - logger.Printf("Generated x509 Certifcate for %s", targetUser) + logger.Printf("Generated x509 Certificate for %s (from %s). Serial: %s", + targetUser, clientIpAddress, parsedCert.SerialNumber.String()) go func(username string, certType string) { metricsMutex.Lock() defer metricsMutex.Unlock() diff --git a/cmd/keymasterd/certgen_test.go b/cmd/keymasterd/certgen_test.go index e8bd4b87..9bfdd8a5 100644 --- a/cmd/keymasterd/certgen_test.go +++ b/cmd/keymasterd/certgen_test.go @@ -52,7 +52,7 @@ const invalidSSHFileBadKeyData = `ssh-rsa AAAAB3NzaC1kc3dddMAAACBALd5BLQoXxeJHHM const testDuration = time.Duration(120 * time.Second) -/// X509section (this is from certgen TODO: make public) +// X509section (this is from certgen TODO: make public) func getPubKeyFromPem(pubkey string) (pub interface{}, err error) { block, rest := pem.Decode([]byte(pubkey)) if block == nil || block.Type != "PUBLIC KEY" { @@ -226,3 +226,44 @@ func TestGenSSHEd25519(t *testing.T) { } } + +func TestExpandSSHExtensions(t *testing.T) { + state, passwdFile, err := setupValidRuntimeStateSigner(t) + if err != nil { + t.Fatal(err) + } + defer os.Remove(passwdFile.Name()) // clean up + state.Config.Base.SSHCertConfig.Extensions = []sshExtension{ + sshExtension{ + Key: "user:username", + Value: "$USERNAME", + }, + sshExtension{ + Key: "key:$USERNAME", + Value: "value:userkey", + }, + } + extensions, err := state.expandSSHExtensions("username") + if err != nil { + t.Fatal(err) + } + if extensions == nil { + t.Fatal("nil extension") + } + compareMap := map[string]string{ + "user:username": "username", + "key:username": "value:userkey", + } + if len(state.Config.Base.SSHCertConfig.Extensions) != len(extensions) { + t.Fatal("incomplete expansion") + } + for key, value := range extensions { + cValue, ok := compareMap[key] + if !ok { + t.Fatal("key not found") + } + if value != cValue { + t.Fatal("value does not match") + } + } +} diff --git a/cmd/keymasterd/config.go b/cmd/keymasterd/config.go index a37349b3..6252ca3b 100644 --- a/cmd/keymasterd/config.go +++ b/cmd/keymasterd/config.go @@ -17,6 +17,7 @@ import ( "io/ioutil" "math/big" "net" + "net/http" "os" "path/filepath" "regexp" @@ -34,13 +35,17 @@ import ( "github.com/Cloud-Foundations/keymaster/keymasterd/admincache" "github.com/Cloud-Foundations/keymaster/lib/authenticators/okta" "github.com/Cloud-Foundations/keymaster/lib/pwauth/command" + "github.com/Cloud-Foundations/keymaster/lib/pwauth/htpassword" "github.com/Cloud-Foundations/keymaster/lib/pwauth/ldap" + "github.com/Cloud-Foundations/keymaster/lib/server/aws_identity_cert" "github.com/Cloud-Foundations/keymaster/lib/vip" + "github.com/duo-labs/webauthn/webauthn" "github.com/howeyc/gopass" "golang.org/x/crypto/openpgp" "golang.org/x/crypto/openpgp/armor" "golang.org/x/crypto/ssh" "golang.org/x/oauth2" + "golang.org/x/time/rate" "gopkg.in/yaml.v2" ) @@ -49,37 +54,56 @@ type autoUnseal struct { AwsSecretKey string `yaml:"aws_secret_key"` } +type sshExtension struct { + Key string `yaml:"key"` + Value string `yaml:"value"` +} + +type sshCertConfig struct { + Extensions []sshExtension `yaml:"extensions"` +} + type baseConfig struct { - HttpAddress string `yaml:"http_address"` - AdminAddress string `yaml:"admin_address"` - HttpRedirectPort uint16 `yaml:"http_redirect_port"` - TLSCertFilename string `yaml:"tls_cert_filename"` - TLSKeyFilename string `yaml:"tls_key_filename"` - ACME acmecfg.AcmeConfig - SSHCAFilename string `yaml:"ssh_ca_filename"` - Ed25519CAFilename string `yaml:"ed25519_ca_keyfilename"` - AutoUnseal autoUnseal `yaml:"auto_unseal"` - HtpasswdFilename string `yaml:"htpasswd_filename"` - ExternalAuthCmd string `yaml:"external_auth_command"` - ClientCAFilename string `yaml:"client_ca_filename"` - KeymasterPublicKeysFilename string `yaml:"keymaster_public_keys_filename"` - HostIdentity string `yaml:"host_identity"` - KerberosRealm string `yaml:"kerberos_realm"` - DataDirectory string `yaml:"data_directory"` - SharedDataDirectory string `yaml:"shared_data_directory"` - HideStandardLogin bool `yaml:"hide_standard_login"` - AllowedAuthBackendsForCerts []string `yaml:"allowed_auth_backends_for_certs"` - AllowedAuthBackendsForWebUI []string `yaml:"allowed_auth_backends_for_webui"` - AllowSelfServiceBootstrapOTP bool `yaml:"allow_self_service_bootstrap_otp"` - AdminUsers []string `yaml:"admin_users"` - AdminGroups []string `yaml:"admin_groups"` - PublicLogs bool `yaml:"public_logs"` - SecsBetweenDependencyChecks int `yaml:"secs_between_dependency_checks"` - AutomationUserGroups []string `yaml:"automation_user_groups"` - AutomationUsers []string `yaml:"automation_users"` - DisableUsernameNormalization bool `yaml:"disable_username_normalization"` - EnableLocalTOTP bool `yaml:"enable_local_totp"` - EnableBootstrapOTP bool `yaml:"enable_bootstrapotp"` + HttpAddress string `yaml:"http_address"` + AdminAddress string `yaml:"admin_address"` + HttpRedirectPort uint16 `yaml:"http_redirect_port"` + TLSCertFilename string `yaml:"tls_cert_filename"` + TLSKeyFilename string `yaml:"tls_key_filename"` + ACME acmecfg.AcmeConfig + SSHCAFilename string `yaml:"ssh_ca_filename"` + Ed25519CAFilename string `yaml:"ed25519_ca_keyfilename"` + AutoUnseal autoUnseal `yaml:"auto_unseal"` + HtpasswdFilename string `yaml:"htpasswd_filename"` + ExternalAuthCmd string `yaml:"external_auth_command"` + ClientCAFilename string `yaml:"client_ca_filename"` + KeymasterPublicKeysFilename string `yaml:"keymaster_public_keys_filename"` + HostIdentity string `yaml:"host_identity"` + KerberosRealm string `yaml:"kerberos_realm"` + DataDirectory string `yaml:"data_directory"` + SharedDataDirectory string `yaml:"shared_data_directory"` + AllowedAuthBackendsForCerts []string `yaml:"allowed_auth_backends_for_certs"` + AllowedAuthBackendsForWebUI []string `yaml:"allowed_auth_backends_for_webui"` + AllowSelfServiceBootstrapOTP bool `yaml:"allow_self_service_bootstrap_otp"` + AdminUsers []string `yaml:"admin_users"` + AdminGroups []string `yaml:"admin_groups"` + PublicLogs bool `yaml:"public_logs"` + SecsBetweenDependencyChecks int `yaml:"secs_between_dependency_checks"` + AutomationUserGroups []string `yaml:"automation_user_groups"` + AutomationUsers []string `yaml:"automation_users"` + DisableUsernameNormalization bool `yaml:"disable_username_normalization"` + EnableLocalTOTP bool `yaml:"enable_local_totp"` + EnableBootstrapOTP bool `yaml:"enable_bootstrapotp"` + WebauthTokenForCliLifetime time.Duration `yaml:"webauth_token_for_cli_lifetime"` + PasswordAttemptGlobalBurstLimit uint `yaml:"password_attempt_global_burst_limit"` + PasswordAttemptGlobalRateLimit rate.Limit `yaml:"password_attempt_global_rate_limit"` + SSHCertConfig sshCertConfig `yaml:"ssh_cert_config"` +} + +type awsCertsConfig struct { + AllowedAccounts []string `yaml:"allowed_accounts"` + ListAccountsRole string `yaml:"list_accounts_role"` + allowedAccounts map[string]struct{} + organisationAccounts map[string]struct{} } type emailConfig struct { @@ -122,22 +146,24 @@ type UserInfoSouces struct { } type Oauth2Config struct { - Config *oauth2.Config - Enabled bool `yaml:"enabled"` - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - TokenUrl string `yaml:"token_url"` - AuthUrl string `yaml:"auth_url"` - UserinfoUrl string `yaml:"userinfo_url"` - Scopes string `yaml:"scopes"` + Config *oauth2.Config + Enabled bool `yaml:"enabled"` + ForceRedirect bool `yaml:"force_redirect"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + TokenUrl string `yaml:"token_url"` + AuthUrl string `yaml:"auth_url"` + UserinfoUrl string `yaml:"userinfo_url"` + Scopes string `yaml:"scopes"` //Todo add allowed orgs... } type OpenIDConnectClientConfig struct { - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - AllowedRedirectURLRE []string `yaml:"allowed_redirect_url_re"` - AllowedRedirectDomains []string `yaml:"allowed_redirect_domains"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + AllowClientChosenAudiences bool `yaml:"allow_client_chose_audiences"` + AllowedRedirectURLRE []string `yaml:"allowed_redirect_url_re"` + AllowedRedirectDomains []string `yaml:"allowed_redirect_domains"` } type OpenIDConnectIDPConfig struct { @@ -164,6 +190,7 @@ type SymantecVIPConfig struct { type AppConfigFile struct { Base baseConfig + AwsCerts awsCertsConfig `yaml:"aws_certs"` DnsLoadBalancer dnslbcfg.Config `yaml:"dns_load_balancer"` Watchdog watchdog.Config `yaml:"watchdog"` Email emailConfig @@ -201,7 +228,7 @@ func (state *RuntimeState) loadTemplates() (err error) { // Load the built-in HTML templates. htmlTemplates := []string{footerTemplateText, loginFormText, secondFactorAuthFormText, profileHTML, usersHTML, headerTemplateText, - newTOTPHTML, newBootstrapOTPPHTML, + newTOTPHTML, newBootstrapOTPPHTML, showAuthTokenHTML, } for _, templateString := range htmlTemplates { _, err = state.htmlTemplate.Parse(templateString) @@ -357,6 +384,8 @@ func loadVerifyConfigFile(configFilename string, } runtimeState.initEmailDefaults() runtimeState.Config.Watchdog.SetDefaults() + runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit = 100 + runtimeState.Config.Base.PasswordAttemptGlobalRateLimit = 10 if _, err := os.Stat(configFilename); os.IsNotExist(err) { err = errors.New("mising config file failure") return nil, err @@ -369,6 +398,15 @@ func loadVerifyConfigFile(configFilename string, if err != nil { return nil, fmt.Errorf("cannot parse config file: %s", err) } + if runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit < 10 { + runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit = 10 + } + if runtimeState.Config.Base.PasswordAttemptGlobalRateLimit < 1 { + runtimeState.Config.Base.PasswordAttemptGlobalRateLimit = 1 + } + runtimeState.passwordAttemptGlobalLimiter = rate.NewLimiter( + runtimeState.Config.Base.PasswordAttemptGlobalRateLimit, + int(runtimeState.Config.Base.PasswordAttemptGlobalBurstLimit)) //share config //runtimeState.userProfile = make(map[string]userProfile) @@ -397,6 +435,15 @@ func loadVerifyConfigFile(configFilename string, u2fAppID = u2fAppID + runtimeState.Config.Base.HttpAddress } u2fTrustedFacets = append(u2fTrustedFacets, u2fAppID) + runtimeState.webAuthn, err = webauthn.New(&webauthn.Config{ + RPDisplayName: "Keymaster Server", // Display Name for your site + RPID: runtimeState.HostIdentity, // Generally the domain name for your site + RPOrigin: u2fAppID, // The origin URL for WebAuthn requests + // RPIcon: "https://duo.com/logo.png", // Optional icon URL for your site + }) + if err != nil { + return nil, err + } if len(runtimeState.Config.Base.KerberosRealm) > 0 { runtimeState.KerberosRealm = &runtimeState.Config.Base.KerberosRealm @@ -508,12 +555,6 @@ func loadVerifyConfigFile(configFilename string, runtimeState.Config.SymantecVIP.Client = &client } - // - if runtimeState.Config.Base.HideStandardLogin && !runtimeState.Config.Oauth2.Enabled { - err := errors.New("invalid configuration... cannot hide std login without enabling oath2") - return nil, err - } - //Load extra templates err = runtimeState.loadTemplates() if err != nil { @@ -525,9 +566,17 @@ func loadVerifyConfigFile(configFilename string, // TODO(rgooch): We should probably support a priority list of // authentication backends which are tried in turn. The current scheme is // hacky and is limited to only one authentication backend. + if runtimeState.Config.Base.HtpasswdFilename != "" { + runtimeState.passwordChecker, err = htpassword.New( + runtimeState.Config.Base.HtpasswdFilename, logger) + if err != nil { + return nil, err + } + } // ExtAuthCommand if len(runtimeState.Config.Base.ExternalAuthCmd) > 0 { - runtimeState.passwordChecker, err = command.New(runtimeState.Config.Base.ExternalAuthCmd, nil, logger) + runtimeState.passwordChecker, err = command.New( + runtimeState.Config.Base.ExternalAuthCmd, nil, logger) if err != nil { return nil, err } @@ -565,10 +614,20 @@ func loadVerifyConfigFile(configFilename string, } logger.Debugf(1, "passwordChecker= %+v", runtimeState.passwordChecker) } + // If not using an OAuth2 IDP for primary authentication, must have an + // alternative enabled. + if runtimeState.passwordChecker == nil && + !runtimeState.Config.Oauth2.Enabled { + return nil, errors.New( + "invalid configuration: no primary authentication method") + } + if runtimeState.Config.Base.SecsBetweenDependencyChecks < 1 { runtimeState.Config.Base.SecsBetweenDependencyChecks = defaultSecsBetweenDependencyChecks } - + if err := runtimeState.configureAwsRoles(); err != nil { + return nil, err + } logger.Debugf(1, "End of config initialization: %+v", &runtimeState) // UserInfo setup. @@ -582,6 +641,12 @@ func loadVerifyConfigFile(configFilename string, logger.Println("loaded UserInfo GitDB") } + if runtimeState.Config.Base.WebauthTokenForCliLifetime > + maxWebauthForCliTokenLifetime { + runtimeState.Config.Base.WebauthTokenForCliLifetime = + maxWebauthForCliTokenLifetime + } + // Warn on potential issues warnInsecureConfiguration(&runtimeState) @@ -590,6 +655,21 @@ func loadVerifyConfigFile(configFilename string, return nil, err } + failureWriter := func(w http.ResponseWriter, r *http.Request, + errorString string, code int) { + runtimeState.writeFailureResponse(w, r, code, errorString) + } + runtimeState.awsCertIssuer, err = aws_identity_cert.New( + aws_identity_cert.Params{ + CertificateGenerator: runtimeState.generateRoleCert, + AccountIdValidator: runtimeState.checkAwsAccountAllowed, + FailureWriter: failureWriter, + Logger: logger, + }) + if err != nil { + return nil, err + } + // and we start the cleanup go runtimeState.performStateCleanup(secsBetweenCleanup) diff --git a/cmd/keymasterd/data/session.js b/cmd/keymasterd/data/session.js new file mode 100644 index 00000000..b3ea72f2 --- /dev/null +++ b/cmd/keymasterd/data/session.js @@ -0,0 +1,10 @@ +function showSessionExpiration() { + date = parseInt(document.getElementById("session-data").getAttribute("date")); + expirationTime = new Date(date*1000); + titleText = "Session expires: "+expirationTime.toString(); + authUsername.setAttribute("title", titleText); +} + +document.addEventListener('DOMContentLoaded', function () { + showSessionExpiration(); +}); diff --git a/cmd/keymasterd/idp_oidc.go b/cmd/keymasterd/idp_oidc.go index 99d234d1..41eadcd3 100644 --- a/cmd/keymasterd/idp_oidc.go +++ b/cmd/keymasterd/idp_oidc.go @@ -19,7 +19,6 @@ import ( "github.com/Cloud-Foundations/keymaster/lib/authutil" "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" - "github.com/mendsley/gojwk" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" ) @@ -77,29 +76,23 @@ func (state *RuntimeState) idpOpenIDCDiscoveryHandler(w http.ResponseWriter, r * out.WriteTo(w) } -type jwsKeyList struct { - Keys []*gojwk.Key `json:"keys"` -} - // Need to improve this to account for adding the other signers here. func (state *RuntimeState) idpOpenIDCJWKSHandler(w http.ResponseWriter, r *http.Request) { if state.sendFailureToClientIfLocked(w, r) { return } - var currentKeys jwsKeyList + var currentKeys jose.JSONWebKeySet for _, key := range state.KeymasterPublicKeys { - jwkKey, err := gojwk.PublicKey(key) - if err != nil { - log.Printf("error getting key idpOpenIDCJWKSHandler: %s", err) - state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error") - return - } - jwkKey.Kid, err = getKeyFingerprint(key) + kid, err := getKeyFingerprint(key) if err != nil { log.Printf("error computing key fingerprint in idpOpenIDCJWKSHandler: %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error") return } + jwkKey := jose.JSONWebKey{ + Key: key, + KeyID: kid, + } currentKeys.Keys = append(currentKeys.Keys, jwkKey) } b, err := json.Marshal(currentKeys) @@ -122,20 +115,33 @@ type keymasterdIDPCodeProtectedData struct { } type keymasterdCodeToken struct { - Issuer string `json:"iss"` //keymasterd - Subject string `json:"sub"` //clientID - IssuedAt int64 `json:"iat"` - Expiration int64 `json:"exp"` - Username string `json:"username"` - AuthLevel int64 `json:"auth_level"` - AuthExpiration int64 `json:"auth_exp"` - Nonce string `json:"nonce,omitEmpty"` - RedirectURI string `json:"redirect_uri"` - Scope string `json:"scope"` - Type string `json:"type"` - JWTId string `json:"jti,omitEmpty"` - ProtectedDataKey string `json:"protected_data_key,omitempty"` - ProtectedData string `json:"protected_data,omitempty"` + Issuer string `json:"iss"` //keymasterd + Subject string `json:"sub"` //clientID + IssuedAt int64 `json:"iat"` + Expiration int64 `json:"exp"` + Audience []string `json:"aud"` + Username string `json:"username"` + AuthLevel int64 `json:"auth_level"` + AuthExpiration int64 `json:"auth_exp"` + Nonce string `json:"nonce,omitEmpty"` + RedirectURI string `json:"redirect_uri"` + AccessAudience []string `json:"access_audience,omitempty"` + Scope string `json:"scope"` + Type string `json:"type"` + JWTId string `json:"jti,omitEmpty"` + ProtectedDataKey string `json:"protected_data_key,omitempty"` + ProtectedData string `json:"protected_data,omitempty"` +} + +var ErrorIDPClientNotFound = errors.New("Client id not found") + +func (state *RuntimeState) idpOpenIDCGetClientConfig(client_id string) (*OpenIDConnectClientConfig, error) { + for _, client := range state.Config.OpenIDConnectIDP.Client { + if client.ClientID == client_id { + return &client, nil + } + } + return nil, ErrorIDPClientNotFound } // https://tools.ietf.org/id/draft-ietf-oauth-security-topics-10.html states @@ -145,60 +151,85 @@ type keymasterdCodeToken struct { // 1. redirect_urls scheme MUST be https (to prevent code snooping). // 2. redirect_urls MUST not include a query (to prevent stealing of code with faulty clients (open redirect)) // 3. redirect_url path MUST NOT contain ".." to prevent path traversal attacks -func (state *RuntimeState) idpOpenIDCClientCanRedirect(client_id string, redirect_url string) (bool, error) { - for _, client := range state.Config.OpenIDConnectIDP.Client { - if client.ClientID != client_id { - continue - } - if len(client.AllowedRedirectDomains) < 1 && len(client.AllowedRedirectURLRE) < 1 { - return false, nil - } - matchedRE := false - for _, re := range client.AllowedRedirectURLRE { - matched, err := regexp.MatchString(re, redirect_url) - if err != nil { - return false, err - } - if matched { - matchedRE = true - break - } - } - parsedURL, err := url.Parse(redirect_url) +func (client *OpenIDConnectClientConfig) CanRedirectToURL(redirectUrl string) (bool, *url.URL, error) { + if len(client.AllowedRedirectDomains) < 1 && len(client.AllowedRedirectURLRE) < 1 { + return false, nil, nil + } + matchedRE := false + for _, re := range client.AllowedRedirectURLRE { + matched, err := regexp.MatchString(re, redirectUrl) if err != nil { - logger.Debugf(1, "user passed unparsable url as string err = %s", err) - return false, nil - } - if parsedURL.Scheme != "https" { - return false, nil + return false, nil, err } - if len(parsedURL.RawQuery) > 0 { - return false, nil - } - if strings.Contains(parsedURL.Path, "..") { - return false, nil - } - // if no domains, the matchedRE answer is authoritative - if len(client.AllowedRedirectDomains) < 1 { - return matchedRE, nil - } - if len(client.AllowedRedirectURLRE) < 1 { + if matched { matchedRE = true + break } - matchedDomain := false - for _, domain := range client.AllowedRedirectDomains { - matched := strings.HasSuffix(parsedURL.Hostname(), domain) - if matched { - matchedDomain = true - break - } + } + parsedURL, err := url.Parse(redirectUrl) + if err != nil { + logger.Debugf(1, "user passed unparsable url as string err = %s", err) + return false, nil, nil + } + if parsedURL.Scheme != "https" { + return false, nil, nil + } + if len(parsedURL.RawQuery) > 0 { + return false, nil, nil + } + if strings.Contains(parsedURL.Path, "..") { + return false, nil, nil + } + // if no domains, the matchedRE answer is authoritative + if len(client.AllowedRedirectDomains) < 1 { + return matchedRE, parsedURL, nil + } + if len(client.AllowedRedirectURLRE) < 1 { + matchedRE = true + } + matchedDomain := false + for _, domain := range client.AllowedRedirectDomains { + matched := strings.HasSuffix(parsedURL.Hostname(), domain) + if matched { + matchedDomain = true + break + } + } + return matchedDomain && matchedRE, parsedURL, nil +} + +func (client *OpenIDConnectClientConfig) CorsOriginAllowed(origin string) (bool, error) { + parsedURL, err := url.Parse(origin) + if err != nil { + logger.Debugf(1, "user passed unparsable url as string err = %s", err) + return false, nil + } + if parsedURL.Scheme != "https" { + return false, nil + } + for _, domain := range client.AllowedRedirectDomains { + matched := strings.HasSuffix(parsedURL.Hostname(), domain) + if matched { + return true, nil } - return matchedDomain && matchedRE, nil } return false, nil } -func (state *RuntimeState) idpOpenIDCIsCorsOriginAllowed(origin string, clientId string) (bool, error) { +func (client *OpenIDConnectClientConfig) RequestedAudienceIsAllowed(audience string) bool { + return client.AllowClientChosenAudiences +} + +// This is weak we should be doing hashes +func (client *OpenIDConnectClientConfig) ValidClientSecret(clientSecret string) bool { + return clientSecret == client.ClientSecret +} + +func (client *OpenIDConnectClientConfig) ClientCanDoPKCEAuth() (bool, error) { + return client.ClientSecret == "", nil +} + +func (state *RuntimeState) idpOpenIDCGenericIsCorsOriginAllowed(origin string) (bool, error) { parsedURL, err := url.Parse(origin) if err != nil { logger.Debugf(1, "user passed unparsable url as string err = %s", err) @@ -208,9 +239,6 @@ func (state *RuntimeState) idpOpenIDCIsCorsOriginAllowed(origin string, clientId return false, nil } for _, client := range state.Config.OpenIDConnectIDP.Client { - if clientId != "" && client.ClientID != clientId { - continue - } for _, domain := range client.AllowedRedirectDomains { matched := strings.HasSuffix(parsedURL.Hostname(), domain) if matched { @@ -295,13 +323,13 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter, } // We are now at exploration stage... and will require pre-authed clients. - authUser, _, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { logger.Debugf(1, "%v", err) return } - logger.Debugf(1, "AuthUser of idc auth: %s", authUser) - w.(*instrumentedwriter.LoggingWriter).SetUsername(authUser) + logger.Debugf(1, "AuthUser of idc auth: %s", authData.Username) + w.(*instrumentedwriter.LoggingWriter).SetUsername(authData.Username) // requst MUST be a GET or POST if !(r.Method == "GET" || r.Method == "POST") { state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid Method for Auth Handler") @@ -309,6 +337,15 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter, } err = r.ParseForm() if err != nil { + if err.Error() == "invalid semicolon separator in query" { + state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid URL, contains semicolons") + return + } + if strings.Contains(err.Error(), "invalid") { + state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid URL") + return + } + logger.Printf("idpOpenIDCAuthorizationHandler Error parsing From err: %s", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "") return } @@ -322,7 +359,7 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter, clientID := r.Form.Get("client_id") if clientID == "" { - logger.Debugf(1, "empty client_id abourting") + logger.Debugf(1, "empty client_id aborting") state.writeFailureResponse(w, r, http.StatusBadRequest, "Empty cleint_id for Auth Handler") return } @@ -334,21 +371,31 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter, } } if !validScope { - state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid scope value for Auth Handler") return } - requestRedirectURLString := r.Form.Get("redirect_uri") + oidcClient, err := state.idpOpenIDCGetClientConfig(clientID) + if err != nil { + if err == ErrorIDPClientNotFound { + logger.Debugf(1, "Client Not Found clientID=%s", clientID) + state.writeFailureResponse(w, r, http.StatusBadRequest, "ClientID uknown") + return + } + logger.Printf("%v", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + return + } - ok, err := state.idpOpenIDCClientCanRedirect(clientID, requestRedirectURLString) + requestRedirectURLString := r.Form.Get("redirect_uri") + ok, parsedRedirectURL, err := oidcClient.CanRedirectToURL(requestRedirectURLString) if err != nil { logger.Printf("%v", err) state.writeFailureResponse(w, r, http.StatusInternalServerError, "") return } if !ok { - state.writeFailureResponse(w, r, http.StatusBadRequest, "redirect string not valid or clientID uknown") + state.writeFailureResponse(w, r, http.StatusBadRequest, "redirect string not valid") return } @@ -395,6 +442,28 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter, } } + + //For the initial version we will only allow a single extra audience + + var accessAudience []string + requestedAudience := r.Form.Get("audience") + if requestedAudience != "" { + if !oidcClient.RequestedAudienceIsAllowed(requestedAudience) { + state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid audience") + return + } + validAudience, err := oidcClient.CorsOriginAllowed(requestedAudience) + if err != nil { + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + return + } + if !validAudience { + state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid audience") + return + } + accessAudience = append(accessAudience, requestedAudience) + } + //Dont check for now signerOptions := (&jose.SignerOptions{}).WithType("JWT") //signerOptions.EmbedJWK = true @@ -408,11 +477,12 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter, codeToken.Scope = scope codeToken.AuthExpiration = time.Now().Unix() + maxAgeSecondsAuthCookie codeToken.Expiration = time.Now().Unix() + idpOpenIDCMaxAuthProcessMaxDurationSeconds - codeToken.Username = authUser + codeToken.Username = authData.Username codeToken.RedirectURI = requestRedirectURLString codeToken.Type = "token_endpoint" codeToken.ProtectedData = protectedCipherText codeToken.ProtectedDataKey = protectedCipherTextKeys + codeToken.AccessAudience = accessAudience codeToken.Nonce = r.Form.Get("nonce") // Do nonce complexity check if len(codeToken.Nonce) < 6 && len(codeToken.Nonce) != 0 { @@ -428,8 +498,8 @@ func (state *RuntimeState) idpOpenIDCAuthorizationHandler(w http.ResponseWriter, redirectPath := fmt.Sprintf("%s?code=%s&state=%s", requestRedirectURLString, raw, url.QueryEscape(r.Form.Get("state"))) logger.Debugf(3, "auth request is valid, redirect path=%s", redirectPath) - logger.Printf("IDP: Successful oauth2 authorization: user=%s redirect url=%s", authUser, requestRedirectURLString) - eventNotifier.PublishServiceProviderLoginEvent(requestRedirectURLString, authUser) + logger.Debugf(0, "IDP: Successful oauth2 authorization: user=%s redirect url=%s", authData.Username, parsedRedirectURL.Redacted()) + eventNotifier.PublishServiceProviderLoginEvent(requestRedirectURLString, authData.Username) http.Redirect(w, r, redirectPath, 302) //logger.Printf("raw jwt =%v", raw) } @@ -444,38 +514,21 @@ type openIDConnectIDToken struct { Nonce string `json:"nonce,omitempty"` } -type accessToken struct { +type tokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` IDToken string `json:"id_token"` } -type userInfoToken struct { - Username string `json:"username"` - Scope string `json:"scope"` - Expiration int64 `json:"exp"` - Type string `json:"type"` -} - -func (state *RuntimeState) idpOpenIDCValidClientSecret(clientId string, clientSecret string) bool { - for _, client := range state.Config.OpenIDConnectIDP.Client { - if client.ClientID != clientId { - continue - } - return clientSecret == client.ClientSecret - } - return false -} - -func (state *RuntimeState) idpOpenIDCClientCanDoPKCEAuth(clientId string) (bool, error) { - for _, client := range state.Config.OpenIDConnectIDP.Client { - if client.ClientID != clientId { - continue - } - return client.ClientSecret == "", nil - } - return false, nil +type bearerAccessToken struct { + Issuer string `json:"iss"` + Audience []string `json:"aud,omitempty"` + Username string `json:"username"` + Scope string `json:"scope"` + Expiration int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Type string `json:"type"` } func (state *RuntimeState) idpOpenIDCValidCodeVerifier(clientId string, codeVerifier string, codeToken keymasterdCodeToken) bool { @@ -522,7 +575,7 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http return } if r.Form.Get("grant_type") != "authorization_code" { - logger.Printf("invalid grant type='%s'", r.Form.Get("grant_type")) + logger.Debugf(1, "invalid grant type='%s'", url.QueryEscape(r.Form.Get("grant_type"))) state.writeFailureResponse(w, r, http.StatusBadRequest, "Invalid grant type") return } @@ -604,9 +657,19 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http pass = unescapedPass } } + oidcClient, err := state.idpOpenIDCGetClientConfig(clientID) + if err != nil { + if err == ErrorIDPClientNotFound { + state.writeFailureResponse(w, r, http.StatusBadRequest, "ClientID uknown") + return + } + logger.Printf("%v", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "") + return + } valid := false if len(codeVerifier) > 0 { - canUserCodeVerifier, err := state.idpOpenIDCClientCanDoPKCEAuth(clientID) + canUserCodeVerifier, err := oidcClient.ClientCanDoPKCEAuth() if err != nil { logger.Printf("Error checking if client can do PKCE auth") state.writeFailureResponse(w, r, http.StatusInternalServerError, "") @@ -620,7 +683,7 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http valid = state.idpOpenIDCValidCodeVerifier(clientID, codeVerifier, keymasterToken) } if !valid && len(pass) > 0 { - valid = state.idpOpenIDCValidClientSecret(clientID, pass) + valid = oidcClient.ValidClientSecret(pass) } if !valid { logger.Debugf(0, "Error invalid client secret or code verifier") @@ -628,7 +691,7 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http return } // if we have an origin it should be whitelisted - originIsValid, err := state.idpOpenIDCIsCorsOriginAllowed(r.Header.Get("Origin"), clientID) + originIsValid, err := oidcClient.CorsOriginAllowed(r.Header.Get("Origin")) if err != nil { logger.Printf("Error checking Origin") state.writeFailureResponse(w, r, http.StatusInternalServerError, "") @@ -680,19 +743,28 @@ func (state *RuntimeState) idpOpenIDCTokenHandler(w http.ResponseWriter, r *http signedIdToken, err := jwt.Signed(signer).Claims(idToken).CompactSerialize() if err != nil { - panic(err) + log.Printf("error signing idToken in idpOpenIDCTokenHandler,: %s", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error") + return } logger.Debugf(2, "raw=%s", signedIdToken) - userinfoToken := userInfoToken{Username: keymasterToken.Username, Scope: keymasterToken.Scope} - userinfoToken.Expiration = idToken.Expiration - userinfoToken.Type = "bearer" - signedAccessToken, err := jwt.Signed(signer).Claims(userinfoToken).CompactSerialize() + accessToken := bearerAccessToken{Issuer: state.idpGetIssuer(), + Username: keymasterToken.Username, Scope: keymasterToken.Scope} + accessToken.Expiration = idToken.Expiration + accessToken.Type = "bearer" + accessToken.IssuedAt = time.Now().Unix() + if len(keymasterToken.AccessAudience) > 0 { + accessToken.Audience = append(keymasterToken.AccessAudience, state.idpGetIssuer()+idpOpenIDCUserinfoPath) + } + signedAccessToken, err := jwt.Signed(signer).Claims(accessToken).CompactSerialize() if err != nil { - panic(err) + log.Printf("error signing accessToken in idpOpenIDCTokenHandler: %s", err) + state.writeFailureResponse(w, r, http.StatusInternalServerError, "Internal Error") + return } // The access token will be yet another jwt. - outToken := accessToken{ + outToken := tokenResponse{ AccessToken: signedAccessToken, TokenType: "Bearer", ExpiresIn: int(idToken.Expiration - idToken.IssuedAt), @@ -816,7 +888,7 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter, state.writeFailureResponse(w, r, http.StatusBadRequest, "Options MUST contain origin") return } - originIsValid, err := state.idpOpenIDCIsCorsOriginAllowed(origin, "") + originIsValid, err := state.idpOpenIDCGenericIsCorsOriginAllowed(origin) if err != nil { logger.Printf("Error checking Origin") state.writeFailureResponse(w, r, http.StatusInternalServerError, "") @@ -851,7 +923,6 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter, } logger.Debugf(1, "access_token='%s'", accessToken) if accessToken == "" { - logger.Printf("access_token='%s'", accessToken) state.writeFailureResponse(w, r, http.StatusBadRequest, "Missing access token") return @@ -864,7 +935,7 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter, return } logger.Debugf(1, "tok=%+v", tok) - parsedAccessToken := userInfoToken{} + parsedAccessToken := bearerAccessToken{} if err := state.JWTClaims(tok, &parsedAccessToken); err != nil { logger.Printf("err=%s", err) state.writeFailureResponse(w, r, http.StatusBadRequest, "bad code") @@ -874,13 +945,31 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter, // Now we check for validity. if parsedAccessToken.Expiration < time.Now().Unix() { logger.Printf("expired token attempted to be used for bearer") - state.writeFailureResponse(w, r, http.StatusUnauthorized, "") + state.writeFailureResponse(w, r, http.StatusUnauthorized, "Expired Token") return } if parsedAccessToken.Type != "bearer" { - state.writeFailureResponse(w, r, http.StatusUnauthorized, "") + state.writeFailureResponse(w, r, http.StatusUnauthorized, "Wrong Token Type") return } + if parsedAccessToken.Issuer != state.idpGetIssuer() { + state.writeFailureResponse(w, r, http.StatusUnauthorized, "Invalid Token Issuer") + return + } + if len(parsedAccessToken.Audience) > 0 { + hasUserinfoAudience := false + userInfoURL := state.idpGetIssuer() + idpOpenIDCUserinfoPath + for _, audience := range parsedAccessToken.Audience { + if audience == userInfoURL { + hasUserinfoAudience = true + break + } + } + if !hasUserinfoAudience { + state.writeFailureResponse(w, r, http.StatusUnauthorized, "Invalid Audience in token") + return + } + } // Get email from LDAP if available. defaultEmailDomain := state.HostIdentity if len(state.Config.OpenIDConnectIDP.DefaultEmailDomain) > 3 { @@ -927,7 +1016,7 @@ func (state *RuntimeState) idpOpenIDCUserinfoHandler(w http.ResponseWriter, json.Indent(&out, b, "", "\t") w.Header().Set("Content-Type", "application/json") - originIsValid, err := state.idpOpenIDCIsCorsOriginAllowed(origin, "") + originIsValid, err := state.idpOpenIDCGenericIsCorsOriginAllowed(origin) if err != nil { logger.Printf("Error checking Origin, allowing to continue without origin header") } diff --git a/cmd/keymasterd/idp_oidc_test.go b/cmd/keymasterd/idp_oidc_test.go index 000e8f00..1f0f78bd 100644 --- a/cmd/keymasterd/idp_oidc_test.go +++ b/cmd/keymasterd/idp_oidc_test.go @@ -1,6 +1,8 @@ package main import ( + "crypto/ed25519" + "crypto/rand" "encoding/json" stdlog "log" "net/http" @@ -69,6 +71,19 @@ func TestIDPOpenIDCJWKSHandler(t *testing.T) { if err != nil { t.Fatal(err) } + // now add Ed25519 key to set of public keys + _, ed25519Priv, err := ed25519.GenerateKey(rand.Reader) + state.KeymasterPublicKeys = append(state.KeymasterPublicKeys, ed25519Priv.Public()) + req2, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Fatal(err) + } + _, err = checkRequestHandlerCode(req2, state.idpOpenIDCJWKSHandler, http.StatusOK) + if err != nil { + t.Fatal(err) + } + // TODO: verify contents returned + } func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) { @@ -132,6 +147,7 @@ func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) { rr, err := checkRequestHandlerCode(postReq, state.idpOpenIDCAuthorizationHandler, http.StatusFound) if err != nil { + t.Logf("bad handler code %+v", rr) t.Fatal(err) } t.Logf("%+v", rr) @@ -174,7 +190,7 @@ func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - resultAccessToken := accessToken{} + resultAccessToken := tokenResponse{} body := tokenRR.Result().Body err = json.NewDecoder(body).Decode(&resultAccessToken) if err != nil { @@ -200,6 +216,67 @@ func TestIDPOpenIDCAuthorizationHandlerSuccess(t *testing.T) { } +// Related to Issue 141: U2F redirect comes w/ semicolons +func TestIDPOpenIDCAuthorizationInvalidURL(t *testing.T) { + badURLList := []string{ + "/idp/oauth2/authorize?client_id=generc-purestorage&redirect_uri=https%3Acloudgate.example.com%2Foauth2%2Fredirectendpoint&response_type=code&scope=openid+mail+profile&state=eyJhbGciOiJIUzI1NiIsInR5cCI", + } + + state, passwdFile, err := setupValidRuntimeStateSigner(t) + if err != nil { + t.Fatal(err) + } + defer os.Remove(passwdFile.Name()) // clean up + state.pendingOauth2 = make(map[string]pendingAuth2Request) + state.Config.Base.AllowedAuthBackendsForWebUI = []string{"password"} + state.signerPublicKeyToKeymasterKeys() + state.HostIdentity = "localhost" + + valid_client_id := "valid_client_id" + valid_client_secret := "secret_password" + //valid_redirect_uri := "https://localhost:12345" + clientConfig := OpenIDConnectClientConfig{ClientID: valid_client_id, ClientSecret: valid_client_secret, AllowedRedirectURLRE: []string{"localhost"}} + state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig) + + //url := idpOpenIDCAuthorizationPath + req, err := http.NewRequest("GET", idpOpenIDCAuthorizationPath, nil) + if err != nil { + t.Fatal(err) + } + + //First we do a simple request.. no auth should fail for now.. after build out it + // should be a redirect to the login page + _, err = checkRequestHandlerCode(req, state.idpOpenIDCAuthorizationHandler, http.StatusUnauthorized) + if err != nil { + t.Fatal(err) + } + // now we add a cookie for auth + cookieVal, err := state.setNewAuthCookie(nil, "username", AuthTypePassword) + if err != nil { + t.Fatal(err) + } + authCookie := http.Cookie{Name: authCookieName, Value: cookieVal} + req.AddCookie(&authCookie) + // and we retry with no params... it should fail again + _, err = checkRequestHandlerCode(req, state.idpOpenIDCAuthorizationHandler, http.StatusBadRequest) + if err != nil { + t.Fatal(err) + } + + for _, invalidURL := range badURLList { + req, err := http.NewRequest("GET", invalidURL, nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(&authCookie) + _, err = checkRequestHandlerCode(req, state.idpOpenIDCAuthorizationHandler, http.StatusBadRequest) + if err != nil { + t.Fatal(err) + } + } + +} + func TestIdpOpenIDCClientCanRedirectFilters(t *testing.T) { state, passwdFile, err := setupValidRuntimeStateSigner(t) if err != nil { @@ -232,8 +309,12 @@ func TestIdpOpenIDCClientCanRedirectFilters(t *testing.T) { } testConfigClients := []string{"weakREWithDomains", "onlyWithDomains"} for _, clientID := range testConfigClients { + client, err := state.idpOpenIDCGetClientConfig(clientID) + if err != nil { + t.Fatal(err) + } for _, mustFailURL := range attackerTestURLS { - resultMatch, err := state.idpOpenIDCClientCanRedirect(clientID, mustFailURL) + resultMatch, _, err := client.CanRedirectToURL(mustFailURL) if err != nil { t.Fatal(err) } @@ -242,13 +323,16 @@ func TestIdpOpenIDCClientCanRedirectFilters(t *testing.T) { } } for _, mustPassURL := range expectedSuccessURLS { - resultMatch, err := state.idpOpenIDCClientCanRedirect(clientID, mustPassURL) + resultMatch, parsedURL, err := client.CanRedirectToURL(mustPassURL) if err != nil { t.Fatal(err) } if resultMatch == false { t.Fatal("should have allowed this url") } + if parsedURL == nil { + t.Fatal("should have parsed this url") + } } } } @@ -290,8 +374,11 @@ func TestIDPOpenIDCPKCEFlowSuccess(t *testing.T) { state.HostIdentity = "localhost" valid_client_id := "valid_client_id" valid_redirect_uri := "https://localhost:12345" + nonPKCEclientID := "nonPKCEClientId" clientConfig := OpenIDConnectClientConfig{ClientID: valid_client_id, ClientSecret: "", AllowedRedirectURLRE: []string{"localhost"}} + clientConfig2 := OpenIDConnectClientConfig{ClientID: nonPKCEclientID, ClientSecret: "supersecret", AllowedRedirectURLRE: []string{"localhost"}} state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig) + state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig2) // now we add a cookie for auth cookieVal, err := state.setNewAuthCookie(nil, "username", AuthTypePassword) if err != nil { @@ -352,7 +439,7 @@ func TestIDPOpenIDCPKCEFlowSuccess(t *testing.T) { } // now a good verifier, but bad client_id badVerifierTokenForm.Set("code_verifier", CodeVerifier.String()) - badVerifierTokenForm.Set("client_id", "invalidClientID") + badVerifierTokenForm.Set("client_id", nonPKCEclientID) badVerifierTokenReq, err = http.NewRequest("POST", idpOpenIDCTokenPath, strings.NewReader(badVerifierTokenForm.Encode())) if err != nil { t.Fatal(err) @@ -380,24 +467,155 @@ func TestIDPOpenIDCPKCEFlowSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - resultAccessToken := accessToken{} + resultAccessToken := tokenResponse{} + body := tokenRR.Result().Body + err = json.NewDecoder(body).Decode(&resultAccessToken) + if err != nil { + t.Fatal(err) + } + t.Logf("resultAccessToken='%+v'", resultAccessToken) + //now the userinfo + userinfoForm := url.Values{} + userinfoForm.Add("access_token", resultAccessToken.AccessToken) + userinfoReq, err := http.NewRequest("POST", idpOpenIDCUserinfoPath, strings.NewReader(userinfoForm.Encode())) + if err != nil { + t.Fatal(err) + } + userinfoReq.Header.Add("Content-Length", strconv.Itoa(len(userinfoForm.Encode()))) + userinfoReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + _, err = checkRequestHandlerCode(userinfoReq, state.idpOpenIDCUserinfoHandler, http.StatusOK) + if err != nil { + t.Fatal(err) + } +} + +// we use a third party code generator to check some of the compatiblity issues +func TestIDPOpenIDCPKCEFlowWithAudienceSuccess(t *testing.T) { + state, passwdFile, err := setupValidRuntimeStateSigner(t) + if err != nil { + t.Fatal(err) + } + defer os.Remove(passwdFile.Name()) // clean up + state.pendingOauth2 = make(map[string]pendingAuth2Request) + state.Config.Base.AllowedAuthBackendsForWebUI = []string{"password"} + state.signerPublicKeyToKeymasterKeys() + state.HostIdentity = "localhost" + + valid_client_id := "valid_client_id" + //valid_client_secret := "secret_password" + valid_redirect_uri := "https://localhost:12345" + clientConfig := OpenIDConnectClientConfig{ClientID: valid_client_id, ClientSecret: "", + AllowClientChosenAudiences: true, + AllowedRedirectURLRE: []string{"localhost"}, AllowedRedirectDomains: []string{"localhost"}, + } + state.Config.OpenIDConnectIDP.Client = append(state.Config.OpenIDConnectIDP.Client, clientConfig) + + // now we add a cookie for auth + cookieVal, err := state.setNewAuthCookie(nil, "username", AuthTypePassword) + if err != nil { + t.Fatal(err) + } + authCookie := http.Cookie{Name: authCookieName, Value: cookieVal} + + //prepare code challenge + var CodeVerifier, _ = cv.CreateCodeVerifier() + + // Create code_challenge with S256 method + codeChallenge := CodeVerifier.CodeChallengeS256() + + // add the required params + form := url.Values{} + form.Add("scope", "openid") + form.Add("response_type", "code") + form.Add("client_id", valid_client_id) + form.Add("redirect_uri", valid_redirect_uri) + form.Add("nonce", "123456789") + form.Add("state", "this is my state") + form.Add("code_challenge_method", "S256") + form.Add("code_challenge", codeChallenge) + form.Add("audience", "https://api.localhost") + + postReq, err := http.NewRequest("POST", idpOpenIDCAuthorizationPath, strings.NewReader(form.Encode())) + if err != nil { + t.Fatal(err) + } + postReq.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) + postReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + postReq.AddCookie(&authCookie) + rr, err := checkRequestHandlerCode(postReq, state.idpOpenIDCAuthorizationHandler, http.StatusFound) + if err != nil { + t.Fatal(err) + } + t.Logf("%+v", rr) + locationText := rr.Header().Get("Location") + t.Logf("location=%s", locationText) + location, err := url.Parse(locationText) + if err != nil { + t.Fatal(err) + } + rCode := location.Query().Get("code") + t.Logf("rCode=%s", rCode) + + //now we do a token request + tokenForm := url.Values{} + tokenForm.Add("grant_type", "authorization_code") + tokenForm.Add("redirect_uri", valid_redirect_uri) + tokenForm.Add("code", rCode) + tokenForm.Add("client_id", valid_client_id) + tokenForm.Add("code_verifier", CodeVerifier.String()) + + tokenReq, err := http.NewRequest("POST", idpOpenIDCTokenPath, strings.NewReader(tokenForm.Encode())) + if err != nil { + t.Fatal(err) + } + tokenReq.Header.Add("Content-Length", strconv.Itoa(len(tokenForm.Encode()))) + tokenReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + tokenRR, err := checkRequestHandlerCode(tokenReq, state.idpOpenIDCTokenHandler, http.StatusOK) + if err != nil { + t.Fatal(err) + } + resultAccessToken := tokenResponse{} body := tokenRR.Result().Body err = json.NewDecoder(body).Decode(&resultAccessToken) if err != nil { t.Fatal(err) } t.Logf("resultAccessToken='%+v'", resultAccessToken) + + // lets parse the access token to ensure the requested audience is there. + tok, err := jwt.ParseSigned(resultAccessToken.AccessToken) + if err != nil { + t.Fatal(err) + } + logger.Debugf(1, "tok=%+v", tok) + parsedAccessToken := bearerAccessToken{} + if err := state.JWTClaims(tok, &parsedAccessToken); err != nil { + t.Fatal(err) + } + t.Logf("parsedAccessToken Data ='%+v'", parsedAccessToken) + if len(parsedAccessToken.Audience) != 2 { + t.Fatalf("should have had only 2 audiences") + } + if parsedAccessToken.Audience[0] != "https://api.localhost" { + t.Fatalf("0th audience is not the one requested") + } + //now the userinfo userinfoForm := url.Values{} userinfoForm.Add("access_token", resultAccessToken.AccessToken) + userinfoReq, err := http.NewRequest("POST", idpOpenIDCUserinfoPath, strings.NewReader(userinfoForm.Encode())) if err != nil { t.Fatal(err) } userinfoReq.Header.Add("Content-Length", strconv.Itoa(len(userinfoForm.Encode()))) userinfoReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + _, err = checkRequestHandlerCode(userinfoReq, state.idpOpenIDCUserinfoHandler, http.StatusOK) if err != nil { t.Fatal(err) } + } diff --git a/cmd/keymasterd/jwt.go b/cmd/keymasterd/jwt.go index 9c6299d9..3474aaa9 100644 --- a/cmd/keymasterd/jwt.go +++ b/cmd/keymasterd/jwt.go @@ -46,7 +46,8 @@ func (state *RuntimeState) JWTClaims(t *jwt.JSONWebToken, dest ...interface{}) ( return err } -func (state *RuntimeState) genNewSerializedAuthJWT(username string, authLevel int) (string, error) { +func (state *RuntimeState) genNewSerializedAuthJWT(username string, + authLevel int, durationSeconds int64) (string, error) { signerOptions := (&jose.SignerOptions{}).WithType("JWT") signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: state.Signer}, signerOptions) if err != nil { @@ -57,12 +58,17 @@ func (state *RuntimeState) genNewSerializedAuthJWT(username string, authLevel in Audience: []string{issuer}, AuthType: authLevel, TokenType: "keymaster_auth"} authToken.NotBefore = time.Now().Unix() authToken.IssuedAt = authToken.NotBefore - authToken.Expiration = authToken.IssuedAt + maxAgeSecondsAuthCookie // TODO seek the actual duration - + authToken.Expiration = authToken.IssuedAt + durationSeconds return jwt.Signed(signer).Claims(authToken).CompactSerialize() } -func (state *RuntimeState) getAuthInfoFromAuthJWT(serializedToken string) (rvalue authInfo, err error) { +func (state *RuntimeState) getAuthInfoFromAuthJWT(serializedToken string) ( + rvalue authInfo, err error) { + return state.getAuthInfoFromJWT(serializedToken, "keymaster_auth") +} + +func (state *RuntimeState) getAuthInfoFromJWT(serializedToken, + tokenType string) (rvalue authInfo, err error) { tok, err := jwt.ParseSigned(serializedToken) if err != nil { return rvalue, err @@ -74,16 +80,16 @@ func (state *RuntimeState) getAuthInfoFromAuthJWT(serializedToken string) (rvalu } //At this stage is now crypto verified, now is time to verify sane values issuer := state.idpGetIssuer() - if inboundJWT.Issuer != issuer || inboundJWT.TokenType != "keymaster_auth" || + if inboundJWT.Issuer != issuer || inboundJWT.TokenType != tokenType || len(inboundJWT.Audience) < 1 || inboundJWT.Audience[0] != issuer || inboundJWT.NotBefore > time.Now().Unix() { err = errors.New("invalid JWT values") return rvalue, err } - - rvalue.Username = inboundJWT.Subject rvalue.AuthType = inboundJWT.AuthType rvalue.ExpiresAt = time.Unix(inboundJWT.Expiration, 0) + rvalue.IssuedAt = time.Unix(inboundJWT.IssuedAt, 0) + rvalue.Username = inboundJWT.Subject return rvalue, nil } diff --git a/cmd/keymasterd/logFilter.go b/cmd/keymasterd/logFilter.go index d3a2e1c1..ec4c2cc3 100644 --- a/cmd/keymasterd/logFilter.go +++ b/cmd/keymasterd/logFilter.go @@ -33,7 +33,7 @@ func (state *RuntimeState) sendFailureToClientIfNotAdminUserOrCA( state.logger.Debugf(4, "request is TLS %+v", r.TLS) if len(r.TLS.VerifiedChains) > 0 { state.logger.Debugf(4, "%+v", r.TLS.VerifiedChains[0][0].Subject) - username, err := state.getUsernameIfKeymasterSigned( + username, _, err := state.getUsernameIfKeymasterSigned( r.TLS.VerifiedChains) if err != nil { state.logger.Println(err) @@ -46,11 +46,11 @@ func (state *RuntimeState) sendFailureToClientIfNotAdminUserOrCA( } return false } - username, _, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) + authData, err := state.checkAuth(w, r, state.getRequiredWebUIAuthLevel()) if err != nil { return true } - if !state.IsAdminUser(username) { + if !state.IsAdminUser(authData.Username) { http.Error(w, "Not an admin user", http.StatusUnauthorized) return true } diff --git a/cmd/keymasterd/main_test.go b/cmd/keymasterd/main_test.go index 3e05ba69..cc590e0a 100644 --- a/cmd/keymasterd/main_test.go +++ b/cmd/keymasterd/main_test.go @@ -17,10 +17,13 @@ import ( "strings" "testing" + "golang.org/x/time/rate" + "github.com/Cloud-Foundations/Dominator/lib/log/debuglogger" "github.com/Cloud-Foundations/golib/pkg/log/testlogger" "github.com/Cloud-Foundations/keymaster/keymasterd/eventnotifier" "github.com/Cloud-Foundations/keymaster/lib/instrumentedwriter" + "github.com/Cloud-Foundations/keymaster/lib/pwauth/htpassword" "github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto" ) @@ -167,7 +170,11 @@ func setupPasswdFile() (f *os.File, err error) { func setupValidRuntimeStateSigner(t *testing.T) ( *RuntimeState, *os.File, error) { - state := RuntimeState{logger: testlogger.New(t)} + logger := testlogger.New(t) + state := RuntimeState{ + passwordAttemptGlobalLimiter: rate.NewLimiter(10.0, 100), + logger: logger, + } //load signer signer, err := getSignerFromPEMBytes([]byte(testSignerPrivateKey)) if err != nil { @@ -187,7 +194,10 @@ func setupValidRuntimeStateSigner(t *testing.T) ( if err != nil { return nil, nil, err } - state.Config.Base.HtpasswdFilename = passwdFile.Name() + state.passwordChecker, err = htpassword.New(passwdFile.Name(), logger) + if err != nil { + return nil, nil, err + } state.totpLocalRateLimit = make(map[string]totpRateLimitInfo) return &state, passwdFile, nil @@ -205,7 +215,8 @@ func TestSuccessFullSigningSSH(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest) + _, err = checkRequestHandlerCode(req, state.certGenHandler, + http.StatusUnauthorized) if err != nil { t.Fatal(err) } @@ -244,7 +255,8 @@ func TestSuccessFullSigningX509(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest) + _, err = checkRequestHandlerCode(req, state.certGenHandler, + http.StatusUnauthorized) if err != nil { t.Fatal(err) } @@ -282,7 +294,8 @@ func TestSuccessFullSigningX509BadLDAPNoGroups(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest) + _, err = checkRequestHandlerCode(req, state.certGenHandler, + http.StatusUnauthorized) if err != nil { t.Fatal(err) } @@ -320,7 +333,8 @@ func TestFailFullSigningX509GroupsBadLDAPNoGroups(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest) + _, err = checkRequestHandlerCode(req, state.certGenHandler, + http.StatusUnauthorized) if err != nil { t.Fatal(err) } @@ -357,7 +371,8 @@ func TestFailCertgenDurationTooLong(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = checkRequestHandlerCode(req, state.certGenHandler, http.StatusBadRequest) + _, err = checkRequestHandlerCode(req, state.certGenHandler, + http.StatusUnauthorized) if err != nil { t.Fatal(err) } @@ -435,7 +450,7 @@ func TestFailSingingExpiredCookie(t *testing.T) { // THis tests needs to be rewritten to have and expired token... need to figure out // the best way to do this. /* - state.authCookie[cookieVal] = authInfo{Username: "username", AuthType: AuthTypeU2F, ExpiresAt: time.Now().Add(-120 * time.Second)} + state.authCookie[cookieVal] = authInfo{IssuedAt: time.Now(), Username: "username", AuthType: AuthTypeU2F, ExpiresAt: time.Now().Add(-120 * time.Second)} _, err = checkRequestHandlerCode(cookieReq, state.certGenHandler, http.StatusUnauthorized) if err != nil { t.Fatal(err) @@ -569,7 +584,10 @@ func TestLoginAPIBasicAuth(t *testing.T) { t.Fatal(err) } defer os.Remove(passwdFile.Name()) // clean up - state.Config.Base.HtpasswdFilename = passwdFile.Name() + state.passwordChecker, err = htpassword.New(passwdFile.Name(), logger) + if err != nil { + t.Fatal(err) + } err = initDB(state) if err != nil { t.Fatal(err) @@ -623,7 +641,10 @@ func TestLoginAPIFormAuth(t *testing.T) { t.Fatal(err) } defer os.Remove(passwdFile.Name()) // clean up - state.Config.Base.HtpasswdFilename = passwdFile.Name() + state.passwordChecker, err = htpassword.New(passwdFile.Name(), logger) + if err != nil { + t.Fatal(err) + } err = initDB(state) if err != nil { t.Fatal(err) diff --git a/cmd/keymasterd/static_files/jquery-3.5.1.min.js b/cmd/keymasterd/static_files/jquery-3.5.1.min.js deleted file mode 100644 index b0614034..00000000 --- a/cmd/keymasterd/static_files/jquery-3.5.1.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.cssHas=ce(function(){try{return C.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),d.cssHas||y.push(":has"),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType&&e.documentElement||e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 c.charCodeAt(0)); +} + +// ArrayBuffer to URLBase64 +function bufferEncode(value) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(value))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "");; +} + + + +function webAuthnRegisterUser() { + + var username = document.getElementById('username').textContent; + $.get( + '/webauthn/RegisterRequest/' + username, + null, + function (data) { + return data + }, + 'json') + .then((credentialCreationOptions) => { + // TODO + //alert(credentialCreationOptions); + console.log(credentialCreationOptions); + credentialCreationOptions.publicKey.challenge = bufferDecode(credentialCreationOptions.publicKey.challenge); + credentialCreationOptions.publicKey.user.id = bufferDecode(credentialCreationOptions.publicKey.user.id); + credentialCreationOptions.publicKey.authenticatorSelection.userVerification="discouraged"; + console.log(credentialCreationOptions); + return navigator.credentials.create({ + publicKey: credentialCreationOptions.publicKey + }) + }) + .then((credential) => { + // TODO + //alert("starting then credentials"); + let attestationObject = credential.response.attestationObject; + let clientDataJSON = credential.response.clientDataJSON; + let rawId = credential.rawId; + + $.post( + '/webauthn/RegisterFinish/' + username, + JSON.stringify({ + id: credential.id, + rawId: bufferEncode(rawId), + type: credential.type, + response: { + attestationObject: bufferEncode(attestationObject), + clientDataJSON: bufferEncode(clientDataJSON), + }, + }), + function (data) { + return data + }, + 'json') + + }) + .then((success) => { + alert("successfully registered " + username + "!") + return + }) + .catch((error) => { + console.log(error) + alert("failed to register " + username) + }); +} + +function webAuthnAuthenticateUser() { + + var username = document.getElementById('username').textContent; + + $.get( + '/webauthn/AuthBegin/' + username, + null, + function (data) { + return data + }, + 'json') + .then((credentialRequestOptions) => { + + credentialRequestOptions.publicKey.challenge = bufferDecode(credentialRequestOptions.publicKey.challenge); + credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem) { + listItem.id = bufferDecode(listItem.id) + }); + //credentialRequestOptions.publicKey.authenticatorSelection.userVerification="discouraged"; + + return navigator.credentials.get({ + publicKey: credentialRequestOptions.publicKey + }) + }) + .then((assertion) => { + + let authData = assertion.response.authenticatorData; + let clientDataJSON = assertion.response.clientDataJSON; + let rawId = assertion.rawId; + let sig = assertion.response.signature; + let userHandle = assertion.response.userHandle; + + $.post( + '/webauthn/AuthFinish/' + username, + JSON.stringify({ + id: assertion.id, + rawId: bufferEncode(rawId), + type: assertion.type, + response: { + authenticatorData: bufferEncode(authData), + clientDataJSON: bufferEncode(clientDataJSON), + signature: bufferEncode(sig), + userHandle: bufferEncode(userHandle), + }, + }), + function (data) { + console.log("Authnenticated: " + data); + alert("successfully authenticated " + username + "!"); + return data + }, + 'json') + }) + .then((success) => { + console.log("button pressed") + return + }) + .catch((error) => { + console.log(error) + alert("failed to authenticate " + username) + }); +} + + + +document.addEventListener('DOMContentLoaded', function () { + document.getElementById('webauthn_auth_button').addEventListener('click', webAuthnAuthenticateUser); + document.getElementById('webauthn_register_button').addEventListener('click', webAuthnRegisterUser); + // main(); +}); diff --git a/cmd/keymasterd/static_files/webui-2fa-okta-push.js b/cmd/keymasterd/static_files/webui-2fa-okta-push.js index 3a56efa8..be32c2cb 100644 --- a/cmd/keymasterd/static_files/webui-2fa-okta-push.js +++ b/cmd/keymasterd/static_files/webui-2fa-okta-push.js @@ -3,7 +3,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { // Action to be performed when the document is read; - var destination = document.getElementById("okta_login_destination").innerHTML; + var destination = document.getElementById("login_destination_input").getAttribute("value"); window.location.href = destination; } }; @@ -29,9 +29,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { // Action to be performed when the document is read; - //var destination = document.getElementById("vip_login_destination").innerHTML; - //window.location.href = destination; - cosole.log("success okta push start") + console.log("success okta push start") } }; xhr.open("GET", "/api/v0/oktaPushStart", true); diff --git a/cmd/keymasterd/static_files/webui-2fa-symc-vip.js b/cmd/keymasterd/static_files/webui-2fa-symc-vip.js index 8224c003..942de5b2 100644 --- a/cmd/keymasterd/static_files/webui-2fa-symc-vip.js +++ b/cmd/keymasterd/static_files/webui-2fa-symc-vip.js @@ -3,7 +3,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { // Action to be performed when the document is read; - var destination = document.getElementById("vip_login_destination").innerHTML; + var destination = document.getElementById("login_destination_input").getAttribute("value"); window.location.href = destination; } }; @@ -28,9 +28,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { // Action to be performed when the document is read; - //var destination = document.getElementById("vip_login_destination").innerHTML; - //window.location.href = destination; - cosole.log("success vip push start") + console.log("success vip push start") } }; xhr.open("GET", "/api/v0/vipPushStart", true); diff --git a/cmd/keymasterd/static_files/webui-2fa-u2f.js b/cmd/keymasterd/static_files/webui-2fa-u2f.js index edddbe00..ef9aed1c 100644 --- a/cmd/keymasterd/static_files/webui-2fa-u2f.js +++ b/cmd/keymasterd/static_files/webui-2fa-u2f.js @@ -1,3 +1,16 @@ +// Base64 to ArrayBuffer +function bufferDecode(value) { + return Uint8Array.from(atob(value), c => c.charCodeAt(0)); +} + +// ArrayBuffer to URLBase64 +function bufferEncode(value) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(value))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "");; +} + function serverError(data) { console.log(data); alert('Server error code ' + data.status + ': ' + data.responseText); @@ -45,7 +58,7 @@ function checkError(resp) { } $.post('/u2f/SignResponse', JSON.stringify(resp)).done(function() { //alert('Success'); - var destination = document.getElementById("u2f_login_destination").innerHTML; + var destination = document.getElementById("login_destination_input").getAttribute("value"); window.location.href = destination; }).fail(serverError); } @@ -57,7 +70,66 @@ function checkError(resp) { }).fail(serverError); } + + +function webAuthnAuthenticateUser2() { + console.log("top of webAuthnAuthenticateUser2"); + $.get( + '/webauthn/AuthBegin/', + null, + function (data) { + return data + }, + 'json') + .then((credentialRequestOptions) => { + credentialRequestOptions.publicKey.challenge = bufferDecode(credentialRequestOptions.publicKey.challenge); + credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem) { + listItem.id = bufferDecode(listItem.id) + }); + + return navigator.credentials.get({ + publicKey: credentialRequestOptions.publicKey + }) + }) + .then((assertion) => { + + let authData = assertion.response.authenticatorData; + let clientDataJSON = assertion.response.clientDataJSON; + let rawId = assertion.rawId; + let sig = assertion.response.signature; + let userHandle = assertion.response.userHandle; + $.post( + '/webauthn/AuthFinish/', + JSON.stringify({ + id: assertion.id, + rawId: bufferEncode(rawId), + type: assertion.type, + response: { + authenticatorData: bufferEncode(authData), + clientDataJSON: bufferEncode(clientDataJSON), + signature: bufferEncode(sig), + userHandle: bufferEncode(userHandle), + }, + }), + function (data) { + console.log("on post with some data " + data) + var destination = document.getElementById("login_destination_input").getAttribute("value"); + window.location.href = destination; + return data; + }, + 'json') + }) + .then((success) => { + console.log("successfully pressed button"); + //hideAllU2FElements(); + }) + .catch((error) => { + console.log(error) + alert("failed to authenticate ") + }); +} + + document.addEventListener('DOMContentLoaded', function () { - //document.getElementById('auth_button').addEventListener('click', sign); - sign(); + webAuthnAuthenticateUser2(); }); diff --git a/cmd/keymasterd/storage.go b/cmd/keymasterd/storage.go index b36c4028..191a488c 100644 --- a/cmd/keymasterd/storage.go +++ b/cmd/keymasterd/storage.go @@ -505,7 +505,8 @@ func (state *RuntimeState) LoadUserProfile(username string) ( decoder := gob.NewDecoder(gobReader) err = decoder.Decode(&defaultProfile) if err != nil { - return nil, false, fromCache, err + return nil, false, fromCache, + fmt.Errorf("error decoding user profile: %s", err) } logger.Debugf(1, "loaded profile=%+v", defaultProfile) return &defaultProfile, true, fromCache, nil diff --git a/cmd/keymasterd/storage_test.go b/cmd/keymasterd/storage_test.go index 0794565a..7b16b543 100644 --- a/cmd/keymasterd/storage_test.go +++ b/cmd/keymasterd/storage_test.go @@ -6,6 +6,8 @@ import ( "os" "testing" + "golang.org/x/time/rate" + "github.com/Cloud-Foundations/Dominator/lib/log/debuglogger" "github.com/Cloud-Foundations/golib/pkg/log/testlogger" "github.com/Cloud-Foundations/keymaster/keymasterd/eventnotifier" @@ -22,7 +24,10 @@ func newTestingState(t *testing.T) (*RuntimeState, string, error) { if err != nil { return nil, "", err } - state := &RuntimeState{logger: testlogger.New(t)} + state := &RuntimeState{ + passwordAttemptGlobalLimiter: rate.NewLimiter(10.0, 100), + logger: testlogger.New(t), + } state.Config.Base.DataDirectory = tmpdir return state, tmpdir, nil } diff --git a/cmd/keymasterd/templateData.go b/cmd/keymasterd/templateData.go index 934448ca..07e42571 100644 --- a/cmd/keymasterd/templateData.go +++ b/cmd/keymasterd/templateData.go @@ -7,12 +7,25 @@ import ( const headerTemplateText = ` {{define "header"}} +{{if .SessionExpires}} +
+{{end}}
- - - - + + + +
{{template "header_extra"}}
{{if .AuthUsername}} {{.AuthUsername}} Logout {{end}}
+ {{template "header_extra"}}
+
+ {{if .AuthUsername}} + {{.AuthUsername}} +   Logout + {{end}} +
@@ -25,7 +38,7 @@ const footerTemplateText = ` @@ -33,16 +46,17 @@ Copright 2017-2019 Symantec Corporation; 2019-2020 Cloud-Foundations.org. ` type loginPageTemplateData struct { - Title string - AuthUsername string - JSSources []string - ShowOauth2 bool - HideStdLogin bool - LoginDestination string - ErrorMessage string + Title string + AuthUsername string + SessionExpires int64 + DefaultUsername string + JSSources []string + ShowBasicAuth bool + ShowOauth2 bool + LoginDestinationInput template.HTML + ErrorMessage string } -//Should be a template const loginFormText = ` {{define "loginPage"}} @@ -64,18 +78,28 @@ const loginFormText = ` {{end}} {{if .ShowOauth2}}

- Oauth2 Login +

+ {{if .LoginDestinationInput}} + {{.LoginDestinationInput}} + {{end}} +

+

- {{end}} - {{if not .HideStdLogin}} + {{end}} + {{if .ShowBasicAuth}} {{template "login_pre_password" .}}
-

Username:

+ {{if .DefaultUsername}} +

Username:

+

Password:

+ {{else}} +

Username:

Password:

- + {{end}} + {{.LoginDestinationInput}}

- {{end}} + {{end}} {{template "login_form_footer" .}} {{template "footer" . }} @@ -86,15 +110,16 @@ const loginFormText = ` ` type secondFactorAuthTemplateData struct { - Title string - AuthUsername string - JSSources []string - ShowBootstrapOTP bool - ShowVIP bool - ShowU2F bool - ShowTOTP bool - ShowOktaOTP bool - LoginDestination string + Title string + AuthUsername string + SessionExpires int64 + JSSources []string + ShowBootstrapOTP bool + ShowVIP bool + ShowU2F bool + ShowTOTP bool + ShowOktaOTP bool + LoginDestinationInput template.HTML } const secondFactorAuthFormText = ` @@ -119,21 +144,19 @@ const secondFactorAuthFormText = `

Keymaster second factor authentication

{{if .ShowBootstrapOTP}} -

Enter Bootstrap OTP value: - + {{.LoginDestinationInput}}

{{end}} {{if .ShowVIP}} -

Enter VIP token value: - + {{.LoginDestinationInput}}

@@ -150,7 +173,6 @@ const secondFactorAuthFormText = ` {{if .ShowU2F}}

-

Authenticate by touching a blinking registered U2F device (insert if not inserted yet)

{{if .ShowVIP}} @@ -167,14 +189,13 @@ const secondFactorAuthFormText = `

Enter TOTP token value: - + {{.LoginDestinationInput}}

{{end}} {{if .ShowOktaOTP}} -

Okta push has been automatically started. If you are not able to receive the @@ -182,7 +203,7 @@ const secondFactorAuthFormText = `

Enter TOTP token value: - + {{.LoginDestinationInput}}

@@ -192,6 +213,7 @@ const secondFactorAuthFormText = `

If you have login issues, you can also + {{.LoginDestinationInput}}

@@ -203,10 +225,11 @@ const secondFactorAuthFormText = ` ` type usersPageTemplateData struct { - Title string - AuthUsername string - JSSources []string - Users []string + Title string + AuthUsername string + SessionExpires int64 + JSSources []string + Users []string } const usersHTML = ` @@ -279,17 +302,18 @@ type profilePageTemplateData struct { Title string AuthUsername string Username string + SessionExpires int64 JSSources []string BootstrapOTP *bootstrapOtpTemplateData ShowU2F bool ShowTOTP bool ReadOnlyMsg string UsersLink bool + ShowLegacyRegister bool RegisteredU2FToken []registeredU2FTokenDisplayInfo RegisteredTOTPDevice []registeredTOTPTDeviceDisplayInfo } -//{{ .Date | formatAsDate}} {{ printf "%-20s" .Description }} {{.AmountInCents | formatAsDollars -}} const profileHTML = ` {{define "userProfilePage"}} @@ -323,23 +347,27 @@ const profileHTML = `
  • Users
  • {{end}} -
    {{if .BootstrapOTP}} +
    Bootstrap OTP fingerprint: {{printf "%x" .BootstrapOTP.Fingerprint}} expires at: {{.BootstrapOTP.ExpiresAt}}

    +

    {{end}}

    U2F

      {{if .ShowU2F}} {{if not .ReadOnlyMsg}} + {{if .ShowLegacyRegister}}
    • - Register token + Register token (Legacy)
    • {{end}} -
    • Authenticate - + {{end}} +
    • Authenticate +
    • +
    • Register U2F device
    • {{else}}
      Your browser does not support U2F. However you can still Enable/Disable/Delete U2F tokens
      @@ -441,6 +469,7 @@ const profileHTML = ` type newTOTPPageTemplateData struct { Title string AuthUsername string + SessionExpires int64 JSSources []string ErrorMessage string TOTPBase64Image template.HTML @@ -500,6 +529,7 @@ const newTOTPHTML = ` type newBootstrapOTPPPageTemplateData struct { Title string AuthUsername string + SessionExpires int64 JSSources []string ErrorMessage string Username string @@ -551,3 +581,57 @@ const newBootstrapOTPPHTML = ` {{end}} ` + +type authCodePageTemplateData struct { + Title string + AuthUsername string + SessionExpires int64 + JSSources []string + ErrorMessage string + Token string +} + +const showAuthTokenHTML = ` +{{define "authTokenPage"}} + + + + {{.Title}} + {{if .JSSources -}} + {{- range .JSSources }} + + {{- end}} + {{- end}} + + + + + +
      + {{template "header" .}} +
      + +

      {{.Title}}

      + + {{if .ErrorMessage}} +

      {{.ErrorMessage}}

      + {{end}} + +
      +

      + {{if .Token}} + Copy into CLI:

      + {{.Token}} +

      + Close this tab once entered. + {{end}} +

      +
      + +
      + {{template "footer" . }} +
      + + +{{end}} +` diff --git a/cmd/keymasterd/userProfile.go b/cmd/keymasterd/userProfile.go new file mode 100644 index 00000000..25b8c789 --- /dev/null +++ b/cmd/keymasterd/userProfile.go @@ -0,0 +1,111 @@ +package main + +import ( + "crypto/elliptic" + "crypto/rand" + "encoding/binary" + "time" + + "github.com/duo-labs/webauthn/webauthn" +) + +// This is the implementation of duo-labs' webauthn User interface +// https://github.com/duo-labs/webauthn/blob/master/webauthn/user.go + +func (u *userProfile) WebAuthnID() []byte { + buf := make([]byte, binary.MaxVarintLen64) + binary.PutUvarint(buf, uint64(u.WebauthnID)) + return buf +} + +func (u *userProfile) WebAuthnName() string { + return u.Username +} + +func (u *userProfile) WebAuthnDisplayName() string { + return u.DisplayName +} + +// From chrome: apparently this needs to be a secure url +func (u *userProfile) WebAuthnIcon() string { + return "" +} + +// This function is needed to create a unified view of all webauthn credentials +func (u *userProfile) WebAuthnCredentials() []webauthn.Credential { + var rvalue []webauthn.Credential + for _, authData := range u.WebauthnData { + if !authData.Enabled { + continue + } + rvalue = append(rvalue, authData.Credential) + } + for _, u2fAuthData := range u.U2fAuthData { + logger.Debugf(3, "WebAuthnCredentials: inside u.U2fAuthData") + if !u2fAuthData.Enabled { + logger.Debugf(3, "WebAuthnCredentials: skipping disabled u2f credential") + continue + } + /* + // A probabilistically-unique byte sequence identifying a public key credential source and its authentication assertions. + ID []byte + // The public key portion of a Relying Party-specific credential key pair, generated by an authenticator and returned to + // a Relying Party at registration time (see also public key credential). The private key portion of the credential key + // pair is known as the credential private key. Note that in the case of self attestation, the credential key pair is also + // used as the attestation key pair, see self attestation for details. + PublicKey []byte + // The attestation format used (if any) by the authenticator when creating the credential. + AttestationType string + // The Authenticator information for a given certificate + Authenticator Authenticator + */ + pubKeyBytes := elliptic.Marshal(u2fAuthData.Registration.PubKey.Curve, u2fAuthData.Registration.PubKey.X, u2fAuthData.Registration.PubKey.Y) + credential := webauthn.Credential{ + AttestationType: "fido-u2f", + ID: u2fAuthData.Registration.KeyHandle, + PublicKey: pubKeyBytes, + Authenticator: webauthn.Authenticator{ + // The AAGUID of the authenticator. An AAGUID is defined as an array containing the globally unique + // identifier of the authenticator model being sought. + AAGUID: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + SignCount: u2fAuthData.Counter, + }, + } + logger.Debugf(2, "WebAuthnCredentials: Added u2f Credential") + rvalue = append(rvalue, credential) + + } + + return rvalue +} + +// This function will eventualy also do migration of credential data if needed +func (u *userProfile) FixupCredential(username string, displayname string) { + if u.DisplayName == "" { + u.DisplayName = displayname + } + // Check for nil.... + if u.WebauthnID == 0 { + buf := make([]byte, 8) + rand.Read(buf) + u.WebauthnID = binary.LittleEndian.Uint64(buf) + } + if u.Username == "" { + u.Username = displayname + } + if u.WebauthnData == nil { + u.WebauthnData = make(map[int64]*webauthAuthData) + } +} + +/// next are not actually from there... but make it simpler +func (u *userProfile) AddWebAuthnCredential(cred webauthn.Credential) error { + index := time.Now().Unix() + authData := webauthAuthData{ + CreatedAt: time.Now(), + Enabled: true, + Credential: cred, + } + u.WebauthnData[index] = &authData + return nil +} diff --git a/eventmon/httpd/showActivity.go b/eventmon/httpd/showActivity.go index 176b80f6..4676b2a9 100644 --- a/eventmon/httpd/showActivity.go +++ b/eventmon/httpd/showActivity.go @@ -73,20 +73,19 @@ func (s state) writeActivity(writer io.Writer, usernames []string, fmt.Fprintln(writer, "SPlogin/SSH/Web/X509 Password/VIPotp/VIPpush/U2F/TOTP") fmt.Fprintln(writer, ``) - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") + tw, _ := html.NewTableWriter(writer, true, + "Username", + "Last Day", + "Last Week", + "Last Month", + "Min Lifetime", + "Med Lifetime", + "Max Lifetime") totals := &statsType{minLifetime: durationMonth * 120, maxLifetime: -1} for _, username := range usernames { - writeUser(writer, username, eventsMap[username], time.Now(), totals) + writeUser(tw, username, eventsMap[username], time.Now(), totals) } - totals.writeHtml(writer, "ALL USERS") + totals.writeHtml(tw, fmt.Sprintf("ALL %d USERS", len(usernames))) fmt.Fprintln(writer, "
      UsernameLast DayLast WeekLast MonthMin LifetimeMed LifetimeMax Lifetime
      ") } @@ -117,20 +116,18 @@ func (s state) writeSPLoginActivity(writer io.Writer, } sort.Sort(sort.Reverse(pairs)) fmt.Fprintln(writer, ``) - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") + tw, _ := html.NewTableWriter(writer, true, + "Service Provider URL", + "Login Count") for _, pair := range pairs { - fmt.Fprintln(writer, " ") - fmt.Fprintf(writer, " \n", pair.url) - fmt.Fprintf(writer, " \n", pair.count) - fmt.Fprintln(writer, " ") + tw.WriteRow("", "", + pair.url, + fmt.Sprintf("%d", pair.count)) } fmt.Fprintln(writer, "
      Service Provider URLLogin Count
      %s%d
      ") } -func writeUser(writer io.Writer, username string, +func writeUser(tw *html.TableWriter, username string, events []eventrecorder.EventType, now time.Time, totals *statsType) { stats := &statsType{ lifetimes: make([]int, 0, len(events)), @@ -171,7 +168,7 @@ func writeUser(writer io.Writer, username string, totals.countOverLastMonth.increment(event) } } - stats.writeHtml(writer, username) + stats.writeHtml(tw, username) } func (counter *counterType) increment(event eventrecorder.EventType) { @@ -239,25 +236,21 @@ type statsType struct { maxLifetime time.Duration } -func (stats *statsType) writeHtml(writer io.Writer, username string) { - fmt.Fprintf(writer, " \n") - fmt.Fprintf(writer, " %s\n", username) - fmt.Fprintf(writer, " %s\n", stats.countOverLastDay.string()) - fmt.Fprintf(writer, " %s\n", stats.countOverLastWeek.string()) - fmt.Fprintf(writer, " %s\n", stats.countOverLastMonth.string()) +func (stats *statsType) writeHtml(tw *html.TableWriter, username string) { + var minLifetime, medLifetime, maxLifetime string if len(stats.lifetimes) > 0 { + minLifetime = format.Duration(stats.minLifetime) sort.Ints(stats.lifetimes) - medLifetime := time.Duration( - stats.lifetimes[len(stats.lifetimes)/2]) * time.Second - fmt.Fprintf(writer, " %s\n", - format.Duration(stats.minLifetime)) - fmt.Fprintf(writer, " %s\n", format.Duration(medLifetime)) - fmt.Fprintf(writer, " %s\n", - format.Duration(stats.maxLifetime)) - } else { - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") - fmt.Fprintln(writer, " ") + medLifetime = format.Duration(time.Duration( + stats.lifetimes[len(stats.lifetimes)/2]) * time.Second) + maxLifetime = format.Duration(stats.maxLifetime) } - fmt.Fprintf(writer, " \n") + tw.WriteRow("", "", + username, + stats.countOverLastDay.string(), + stats.countOverLastWeek.string(), + stats.countOverLastMonth.string(), + minLifetime, + medLifetime, + maxLifetime) } diff --git a/eventmon/monitord/impl.go b/eventmon/monitord/impl.go index 9252bc00..0920da6a 100644 --- a/eventmon/monitord/impl.go +++ b/eventmon/monitord/impl.go @@ -72,6 +72,12 @@ func checkForEvent(channel <-chan struct{}) bool { } } +func (m *Monitor) initKeymasterStatus(addr string) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.keymasterStatus[addr] = errors.New("not yet probed") +} + func (m *Monitor) monitorForever(logger log.Logger) { for ; ; time.Sleep(time.Minute * 2) { m.updateNotifierList(logger) @@ -92,6 +98,7 @@ func (m *Monitor) updateNotifierList(logger log.Logger) { delete(addrsToDelete, addr) } else { logger.Printf("New keymaster server: %s\n", addr) + m.initKeymasterStatus(addr) closeChannel := make(chan struct{}, 1) m.closers[addr] = closeChannel go m.startMonitoring(addr, closeChannel, @@ -108,15 +115,19 @@ func (m *Monitor) updateNotifierList(logger log.Logger) { } } -func (m *Monitor) setKeymasterStatus(addr string, err error) { +// Returns true if the address should not be monitored anymore. +func (m *Monitor) setKeymasterStatus(addr string, err error) bool { m.mutex.Lock() defer m.mutex.Unlock() + if _, ok := m.keymasterStatus[addr]; !ok { + return true + } m.keymasterStatus[addr] = err + return false } func (m *Monitor) startMonitoring(ip string, closeChannel <-chan struct{}, logger log.Logger) { - m.setKeymasterStatus(ip, errors.New("not yet probed")) addr := fmt.Sprintf("%s:%d", ip, m.keymasterServerPortNum) reportedNotReady := false for ; ; time.Sleep(time.Second) { @@ -124,7 +135,9 @@ func (m *Monitor) startMonitoring(ip string, closeChannel <-chan struct{}, return } conn, err := m.dialAndConnect(addr) - m.setKeymasterStatus(ip, err) + if m.setKeymasterStatus(ip, err) { + return + } if err != nil { if strings.Contains(err.Error(), "connection refused") { reportedNotReady = false @@ -174,7 +187,8 @@ func (m *Monitor) connect(rawConn net.Conn) (net.Conn, error) { } } conn := tls.Client(rawConn, - &tls.Config{ServerName: m.keymasterServerHostname}) + &tls.Config{ServerName: m.keymasterServerHostname, + MinVersion: tls.VersionTLS12}) if err := conn.Handshake(); err != nil { return nil, err } @@ -194,10 +208,12 @@ func (m *Monitor) connect(rawConn net.Conn) (net.Conn, error) { return conn, nil } +// Returns true if monitoring should stop (because a message was sent to the +// closeChannel). func (m *Monitor) monitor(conn net.Conn, closeChannel <-chan struct{}, logger log.Logger) (bool, error) { closedChannel := make(chan struct{}, 1) - exitChannel := make(chan struct{}) + exitChannel := make(chan struct{}, 1) go func() { select { case <-closeChannel: diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..2d5c2d65 --- /dev/null +++ b/go.mod @@ -0,0 +1,102 @@ +module github.com/Cloud-Foundations/keymaster + +go 1.18 + +replace github.com/bearsh/hid v1.3.0 => github.com/bearsh/hid v1.5.0 + +require ( + github.com/Cloud-Foundations/Dominator v0.3.1 + github.com/Cloud-Foundations/golib v0.5.0 + github.com/Cloud-Foundations/npipe v0.0.0-20191222161149-761e85df1f92 + github.com/Cloud-Foundations/tricorder v0.0.0-20191102180116-cf6bbf6d0168 + github.com/aws/aws-sdk-go v1.45.19 + github.com/aws/aws-sdk-go-v2 v1.21.0 + github.com/aws/aws-sdk-go-v2/config v1.18.42 + github.com/aws/aws-sdk-go-v2/service/organizations v1.20.6 + github.com/aws/aws-sdk-go-v2/service/sts v1.22.0 + github.com/bearsh/hid v1.5.0 + github.com/cloudflare/cfssl v1.6.4 + github.com/cviecco/argon2 v0.0.0-20171122181119-1dc43e2eaa99 + github.com/duo-labs/webauthn v0.0.0-20221205164246-ebaf9b74c6ec + github.com/flynn/u2f v0.0.0-20180613185708-15554eb68e5d + github.com/foomo/htpasswd v0.0.0-20200116085101-e3a90e78da9c + github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef + github.com/lib/pq v1.10.9 + github.com/marshallbrekka/go-u2fhost v0.0.0-20210111072507-3ccdec8c8105 + github.com/mattn/go-sqlite3 v1.14.17 + github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da + github.com/pquerna/otp v1.4.0 + github.com/prometheus/client_golang v1.17.0 + github.com/tstranex/u2f v1.0.0 + github.com/vjeantet/ldapserver v1.0.1 + golang.org/x/crypto v0.13.0 + golang.org/x/net v0.15.0 + golang.org/x/oauth2 v0.12.0 + golang.org/x/term v0.12.0 + gopkg.in/ldap.v2 v2.5.1 + gopkg.in/square/go-jose.v2 v2.6.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect + github.com/acomagu/bufpipe v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.21.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1 // indirect + github.com/cloudflare/circl v1.3.3 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-git/go-git/v5 v5.9.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/skeema/knownhosts v1.2.1 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) + +require ( + github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.40 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.14.1 // indirect + github.com/aws/smithy-go v1.14.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/boombuler/barcode v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dchest/blake2b v1.0.0 // indirect + github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a // indirect + github.com/fxamacker/cbor/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/certificate-transparency-go v1.1.6 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/time v0.3.0 + golang.org/x/tools v0.13.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect + gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..04826f30 --- /dev/null +++ b/go.sum @@ -0,0 +1,467 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Cloud-Foundations/Dominator v0.3.1 h1:X1QgsisSttgRnLQKkpLga/0Ik47mpFHHgUh801sfNCg= +github.com/Cloud-Foundations/Dominator v0.3.1/go.mod h1:AWCsCpke0lOpHROUDe7sJG6IDqtBfCcOWK1KVHYwJHE= +github.com/Cloud-Foundations/golib v0.5.0 h1:ilTOUDWWWeZBgFZrzM20T7sxbaOG7k5KA7uhLfnMQng= +github.com/Cloud-Foundations/golib v0.5.0/go.mod h1:6ghSQh5/5MPE/4LWbPXC9CpQZMO5LeYtvG3r2K9abwI= +github.com/Cloud-Foundations/npipe v0.0.0-20191222161149-761e85df1f92 h1:EGeQTdSJAOMZiJZN/e+pWletf228/KEZqRWs2p3/l88= +github.com/Cloud-Foundations/npipe v0.0.0-20191222161149-761e85df1f92/go.mod h1:/CR255D7rw/CQr61exJfyW94EXs8dX/w0fLws1cMSwY= +github.com/Cloud-Foundations/tricorder v0.0.0-20191102180116-cf6bbf6d0168 h1:MKB8ovKEveTxJOQuO8x2O4XccNOZmJVaRD6sd8CPtvw= +github.com/Cloud-Foundations/tricorder v0.0.0-20191102180116-cf6bbf6d0168/go.mod h1:g6+RIAw5BCofg90lKHt/er2+ar+Y9eO2CJchLhq3hms= +github.com/GehirnInc/crypt v0.0.0-20190301055215-6c0105aabd46/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs= +github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= +github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/aws/aws-sdk-go v1.44.324 h1:/uja9PtgeeqrZCPOJTenjMLNpciIMuzaRKooq+erG4A= +github.com/aws/aws-sdk-go v1.44.324/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.45.19 h1:+4yXWhldhCVXWFOQRF99ZTJ92t4DtoHROZIbN7Ujk/U= +github.com/aws/aws-sdk-go v1.45.19/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go-v2 v1.20.1 h1:rZBf5DWr7YGrnlTK4kgDQGn1ltqOg5orCYb/UhOFZkg= +github.com/aws/aws-sdk-go-v2 v1.20.1/go.mod h1:NU06lETsFm8fUC6ZjhgDpVBcGZTFQ6XM+LZWZxMI4ac= +github.com/aws/aws-sdk-go-v2 v1.21.0 h1:gMT0IW+03wtYJhRqTVYn0wLzwdnK9sRMcxmtfGzRdJc= +github.com/aws/aws-sdk-go-v2 v1.21.0/go.mod h1:/RfNgGmRxI+iFOB1OeJUyxiU+9s88k3pfHvDagGEp0M= +github.com/aws/aws-sdk-go-v2/config v1.18.33 h1:JKcw5SFxFW/rpM4mOPjv0VQ11E2kxW13F3exWOy7VZU= +github.com/aws/aws-sdk-go-v2/config v1.18.33/go.mod h1:hXO/l9pgY3K5oZJldamP0pbZHdPqqk+4/maa7DSD3cA= +github.com/aws/aws-sdk-go-v2/config v1.18.42 h1:28jHROB27xZwU0CB88giDSjz7M1Sba3olb5JBGwina8= +github.com/aws/aws-sdk-go-v2/config v1.18.42/go.mod h1:4AZM3nMMxwlG+eZlxvBKqwVbkDLlnN2a4UGTL6HjaZI= +github.com/aws/aws-sdk-go-v2/credentials v1.13.32 h1:lIH1eKPcCY1ylR4B6PkBGRWMHO3aVenOKJHWiS4/G2w= +github.com/aws/aws-sdk-go-v2/credentials v1.13.32/go.mod h1:lL8U3v/Y79YRG69WlAho0OHIKUXCyFvSXaIvfo81sls= +github.com/aws/aws-sdk-go-v2/credentials v1.13.40 h1:s8yOkDh+5b1jUDhMBtngF6zKWLDs84chUk2Vk0c38Og= +github.com/aws/aws-sdk-go-v2/credentials v1.13.40/go.mod h1:VtEHVAAqDWASwdOqj/1huyT6uHbs5s8FUHfDQdky/Rs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 h1:DK/9C+UN/X+1+Wm8pqaDksQr2tSLzq+8X1/rI/ZxKEQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8/go.mod h1:ce7BgLQfYr5hQFdy67oX2svto3ufGtm6oBvmsHScI1Q= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 h1:uDZJF1hu0EVT/4bogChk8DyjSF6fof6uL/0Y26Ma7Fg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11/go.mod h1:TEPP4tENqBGO99KwVpV9MlOX4NSrSLP8u3KRy2CDwA8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 h1:c8ed/T9T2K5I+h/JzmF5tpI46+OODQ74dzmdo+QnaMg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38/go.mod h1:qggunOChCMu9ZF/UkAfhTz25+U2rLVb3ya0Ua6TTfCA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 h1:22dGT7PneFMx4+b3pz7lMTRyN8ZKH7M2cW4GP9yUS2g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41/go.mod h1:CrObHAuPneJBlfEJ5T3szXOUkLEThaGfvnhTf33buas= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 h1:hNeAAymUY5gu11WrrmFb3CVIp9Dar9hbo44yzzcQpzA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32/go.mod h1:0ZXSqrty4FtQ7p8TEuRde/SZm9X05KT18LAUlR40Ln0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 h1:SijA0mgjV8E+8G45ltVHs0fvKpTj8xmZJ3VwhGKtUSI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35/go.mod h1:SJC1nEVVva1g3pHAIdCp7QsRIkMmLAgoDquQ9Rr8kYw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 h1:fc0ukRAiP1syoSGZYu+DaE+FulSYhTiJ8WpVu5jElU4= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39/go.mod h1:WLAW8PT7+JhjZfLSWe7WEJaJu0GNo0cKc2Zyo003RBs= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43 h1:g+qlObJH4Kn4n21g69DjspU0hKTjWtq7naZ9OLCv0ew= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43/go.mod h1:rzfdUlfA+jdgLDmPKjd3Chq9V7LVLYo1Nz++Wb91aRo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 h1:dGAseBFEYxth10V23b5e2mAS+tX7oVbfYHD6dnDdAsg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32/go.mod h1:4jwAWKEkCR0anWk5+1RbfSg1R5Gzld7NLiuaq5bTR/Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 h1:CdzPW9kKitgIiLV1+MHobfR5Xg25iYnyzWZhyQuSlDI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35/go.mod h1:QGF2Rs33W5MaN9gYdEQOBBFPLwTZkEhRwI33f7KIG0o= +github.com/aws/aws-sdk-go-v2/service/organizations v1.20.2 h1:DzoYgLyrC5GdrtBZDM8UDtjn7iE/dBdXCbO1KNyBnlE= +github.com/aws/aws-sdk-go-v2/service/organizations v1.20.2/go.mod h1:gg1zipitr1D12WsNU9JZKP1cgsjnSMCZlCQXyWT8rw4= +github.com/aws/aws-sdk-go-v2/service/organizations v1.20.6 h1:ZVk/gzn/N2Wfebn7yboiQi3SB6MhBHvsqr8nyRAtg90= +github.com/aws/aws-sdk-go-v2/service/organizations v1.20.6/go.mod h1:RIwLDY2Rna/SY+FRmhJw2DGpAtkjwxD8eK+OVZvSKgI= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.21.0 h1:z9faFYBvadv9HdY+oFBgxqCnew9TK+jp9ccxktB5fl4= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.21.0/go.mod h1:Z6Oq1mXqvgwmUxvMrV/jMkQhwm06A9XO015dzGnS8TM= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.21.3 h1:H6ZipEknzu7RkJW3w2PP75zd8XOdR35AEY5D57YrJtA= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.21.3/go.mod h1:5W2cYXDPabUmwULErlC92ffLhtTuyv4ai+5HhdbhfNo= +github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 h1:A2RlEMo4SJSwbNoUUgkxTAEMduAy/8wG3eB2b2lP4gY= +github.com/aws/aws-sdk-go-v2/service/sso v1.13.2/go.mod h1:ju+nNXUunfIFamXUIZQiICjnO/TPlOmWcYhZcSy7xaE= +github.com/aws/aws-sdk-go-v2/service/sso v1.14.1 h1:YkNzx1RLS0F5qdf9v1Q8Cuv9NXCL2TkosOxhzlUPV64= +github.com/aws/aws-sdk-go-v2/service/sso v1.14.1/go.mod h1:fIAwKQKBFu90pBxx07BFOMJLpRUGu8VOzLJakeY+0K4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 h1:OJELEgyaT2kmaBGZ+myyZbTTLobfe3ox3FSh5eYK9Qs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2/go.mod h1:ubDBBaDFs1GHijSOTi8ljppML15GLG0HxhILtbjNNYQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1 h1:8lKOidPkmSmfUtiTgtdXWgaKItCZ/g75/jEk6Ql6GsA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1/go.mod h1:yygr8ACQRY2PrEcy3xsUI357stq2AxnFM6DIsR9lij4= +github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 h1:ympg1+Lnq33XLhcK/xTG4yZHPs1Oyxu+6DEWbl7qOzA= +github.com/aws/aws-sdk-go-v2/service/sts v1.21.2/go.mod h1:FQ/DQcOfESELfJi5ED+IPPAjI5xC6nxtSolVVB773jM= +github.com/aws/aws-sdk-go-v2/service/sts v1.22.0 h1:s4bioTgjSFRwOoyEFzAVCmFmoowBgjTR8gkrF/sQ4wk= +github.com/aws/aws-sdk-go-v2/service/sts v1.22.0/go.mod h1:VC7JDqsqiwXukYEDjoHh9U0fOJtNWh04FPQz4ct4GGU= +github.com/aws/smithy-go v1.14.1 h1:EFKMUmH/iHMqLiwoEDx2rRjRQpI1YCn5jTysoaDujFs= +github.com/aws/smithy-go v1.14.1/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aws/smithy-go v1.14.2 h1:MJU9hqBGbvWZdApzpvoF2WAIJDbtjK2NDJSiJP7HblQ= +github.com/aws/smithy-go v1.14.2/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/bearsh/hid v1.5.0 h1:8ChLlc9Nqmlrla4U0QMHLhb/h1hnVcs2Unjoz7iY+vk= +github.com/bearsh/hid v1.5.0/go.mod h1:cs47JobsdK/AHOpD4wgX80i0el+2r/PjZkxrpaJxR84= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8= +github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cviecco/argon2 v0.0.0-20171122181119-1dc43e2eaa99 h1:8co/GRKovq1R4wCidV2GrIf9FQ+2s0bV4IXulkzbkeI= +github.com/cviecco/argon2 v0.0.0-20171122181119-1dc43e2eaa99/go.mod h1:bhY/hbDzWD0J/Sr4zDxR9WaRilSZ06n+qMzGWUjU6yQ= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/blake2b v1.0.0 h1:KK9LimVmE0MjRl9095XJmKqZ+iLxWATvlcpVFRtaw6s= +github.com/dchest/blake2b v1.0.0/go.mod h1:U034kXgbJpCle2wSk5ybGIVhOSHCVLMDqOzcPEA0F7s= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/duo-labs/webauthn v0.0.0-20221205164246-ebaf9b74c6ec h1:darQ1FPPrwlzwmuN3fRMVCrsaCpuDqkKHADYzcMa73M= +github.com/duo-labs/webauthn v0.0.0-20221205164246-ebaf9b74c6ec/go.mod h1:V3q8IgNpNqFio+56G0vy/QZIi7iho65UFrDwdF5OtZA= +github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a h1:fsyWnwbywFpHJS4T55vDW+UUeWP2WomJbB45/jf4If4= +github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a/go.mod h1:Osz+xPHFsGWK9kZCEVcwXazcF/CHjscCVZosNFgwUIY= +github.com/flynn/u2f v0.0.0-20180613185708-15554eb68e5d h1:2D6Rp/MRcrKnRFr7kfgBOJnJPFN0jPfc36ggct5MaK0= +github.com/flynn/u2f v0.0.0-20180613185708-15554eb68e5d/go.mod h1:shcCQPgKtaJz4obqb6Si031WgtSrW+Tj+ZLq/mRNrM8= +github.com/foomo/htpasswd v0.0.0-20200116085101-e3a90e78da9c h1:DBGU7zCwrrPPDsD6+gqKG8UfMxenWg9BOJE/Nmfph+4= +github.com/foomo/htpasswd v0.0.0-20200116085101-e3a90e78da9c/go.mod h1:SHawtolbB0ZOFoRWgDwakX5WpwuIWAK88bUXVZqK0Ss= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= +github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= +github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= +github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A= +github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo= +github.com/go-git/go-git/v5 v5.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY= +github.com/go-git/go-git/v5 v5.9.0/go.mod h1:RKIqga24sWdMGZF+1Ekv9kylsDz6LzdTSI2s/OsZWE0= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/certificate-transparency-go v1.1.6 h1:SW5K3sr7ptST/pIvNkSVWMiJqemRmkjJPPT0jzXdOOY= +github.com/google/certificate-transparency-go v1.1.6/go.mod h1:0OJjOsOk+wj6aYQgP7FU0ioQ0AJUmnWPFMqTjQeazPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3 h1:wIONC+HMNRqmWBjuMxhatuSzHaljStc4gjDeKycxy0A= +github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3/go.mod h1:37YR9jabpiIxsb8X9VCIx8qFOjTDIIrIHHODa8C4gz0= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/marshallbrekka/go-u2fhost v0.0.0-20210111072507-3ccdec8c8105 h1:Si3VAYdC1ZtA58UsDXxlkbpF5EMWxoCJP9gn1cYQ+vc= +github.com/marshallbrekka/go-u2fhost v0.0.0-20210111072507-3ccdec8c8105/go.mod h1:VyqGj5jbZtzHO11cS7rkDh/owr/rNCEM98IhQwWvmXg= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da h1:qiPWuGGr+1GQE6s9NPSK8iggR/6x/V+0snIoOPYsBgc= +github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da/go.mod h1:DvuJJ/w1Y59rG8UTDxsMk5U+UJXJwuvUgbiJSm9yhX8= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= +github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= +github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= +github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ= +github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/vjeantet/ldapserver v1.0.1 h1:3z+TCXhwwDLJC3pZCNbuECPDqC2x1R7qQQbswB1Qwoc= +github.com/vjeantet/ldapserver v1.0.1/go.mod h1:YvUqhu5vYhmbcLReMLrm/Tq3S7Yj43kSVFvvol6Lh6k= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= +golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= +gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/keymaster.spec b/keymaster.spec index 4c9a3084..417fca08 100644 --- a/keymaster.spec +++ b/keymaster.spec @@ -1,5 +1,5 @@ Name: keymaster -Version: 1.9.0 +Version: 1.14.0 Release: 1%{?dist} Summary: Short term access certificate generator and client @@ -41,24 +41,25 @@ short term identity certificates. %build -make +make build %install #%make_install -%{__install} -Dp -m0755 ~/go/bin/keymasterd %{buildroot}%{_sbindir}/keymasterd -%{__install} -Dp -m0755 ~/go/bin/keymaster %{buildroot}%{_bindir}/keymaster -%{__install} -Dp -m0755 ~/go/bin/keymaster-unlocker %{buildroot}%{_bindir}/keymaster-unlocker +%{__install} -Dp -m0755 bin/keymasterd %{buildroot}%{_sbindir}/keymasterd +%{__install} -Dp -m0755 bin/keymaster %{buildroot}%{_bindir}/keymaster +%{__install} -Dp -m0755 bin/keymaster-unlocker %{buildroot}%{_bindir}/keymaster-unlocker install -d %{buildroot}/usr/lib/systemd/system install -p -m 0644 misc/startup/keymaster.service %{buildroot}/usr/lib/systemd/system/keymaster.service install -d %{buildroot}/%{_datarootdir}/keymasterd/static_files/ install -p -m 0644 cmd/keymasterd/static_files/u2f-api.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/u2f-api.js install -p -m 0644 cmd/keymasterd/static_files/keymaster-u2f.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/keymaster-u2f.js +install -p -m 0644 cmd/keymasterd/static_files/keymaster-webauthn.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/keymaster-webauthn.js install -p -m 0644 cmd/keymasterd/static_files/webui-2fa-u2f.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/webui-2fa-u2f.js install -p -m 0644 cmd/keymasterd/static_files/webui-2fa-okta-push.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/webui-2fa-okta-push.js install -p -m 0644 cmd/keymasterd/static_files/webui-2fa-symc-vip.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/webui-2fa-symc-vip.js install -p -m 0644 cmd/keymasterd/static_files/keymaster.css %{buildroot}/%{_datarootdir}/keymasterd/static_files/keymaster.css -install -p -m 0644 cmd/keymasterd/static_files/jquery-3.5.1.min.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/jquery-3.5.1.min.js +install -p -m 0644 cmd/keymasterd/static_files/jquery-3.6.4.min.js %{buildroot}/%{_datarootdir}/keymasterd/static_files/jquery-3.6.4.min.js install -p -m 0644 cmd/keymasterd/static_files/favicon.ico %{buildroot}/%{_datarootdir}/keymasterd/static_files/favicon.ico install -d %{buildroot}/%{_datarootdir}/keymasterd/customization_data/templates install -p -m 0644 cmd/keymasterd/customization_data/templates/header_extra.tmpl %{buildroot}/%{_datarootdir}/keymasterd/customization_data/templates/header_extra.tmpl diff --git a/lib/authenticators/okta/impl.go b/lib/authenticators/okta/impl.go index e3a3ac1e..e7bba181 100644 --- a/lib/authenticators/okta/impl.go +++ b/lib/authenticators/okta/impl.go @@ -175,7 +175,7 @@ func (pa *PasswordAuthenticator) validateUserOTP(username string, otpValue int) } defer resp.Body.Close() if resp.StatusCode == http.StatusForbidden { - return false, nil + continue } if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("bad status: %s", resp.Status) @@ -186,7 +186,7 @@ func (pa *PasswordAuthenticator) validateUserOTP(username string, otpValue int) return false, err } if response.Status != "SUCCESS" { - return false, nil + continue } return true, nil } @@ -202,6 +202,7 @@ func (pa *PasswordAuthenticator) validateUserPush(username string) (PushResponse if userResponse == nil { return PushResponseRejected, nil } + rvalue := PushResponseRejected for _, factor := range userResponse.Embedded.Factor { if !(factor.FactorType == "push" && factor.VendorName == "OKTA") { continue @@ -230,7 +231,8 @@ func (pa *PasswordAuthenticator) validateUserPush(username string) (PushResponse } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return PushResponseRejected, fmt.Errorf("bad status: %s", resp.Status) + rvalue = PushResponseRejected + continue } decoder := json.NewDecoder(resp.Body) var response OktaApiPushResponseType @@ -243,18 +245,20 @@ func (pa *PasswordAuthenticator) validateUserPush(username string) (PushResponse case "MFA_CHALLENGE": break default: - pa.logger.Printf("invalid status") - return PushResponseRejected, nil + pa.logger.Printf("invalid Response status (internal)") + continue } + // switch response.FactorResult { case "WAITING": - return PushResponseWaiting, nil + rvalue = PushResponseWaiting + continue case "TIMEOUT": - return PushResonseTimeout, nil + rvalue = PushResonseTimeout default: - return PushResponseRejected, nil + rvalue = PushResponseRejected } } - return PushResponseRejected, nil + return rvalue, nil } diff --git a/lib/authenticators/okta/okta_test.go b/lib/authenticators/okta/okta_test.go index 9013e2ef..329c39c3 100644 --- a/lib/authenticators/okta/okta_test.go +++ b/lib/authenticators/okta/okta_test.go @@ -2,8 +2,10 @@ package okta import ( "encoding/json" + "log" "net" "net/http" + "strings" "testing" "time" @@ -62,6 +64,11 @@ func factorAuthnHandler(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) return } + path := strings.Split(req.URL.Path, "/") + //log.Printf("Path=%+v", path) + factorId := path[5] // assumes path is Path=[ api v1 authn factors someid verify] + //log.Printf("factorId=%+v", factorId) + // For now we do TOTP only verifyTOTPFactorDataType var otpData OktaApiVerifyTOTPFactorDataType decoder := json.NewDecoder(req.Body) @@ -77,6 +84,17 @@ func factorAuthnHandler(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusForbidden) w.Write([]byte(invalidOTPStringFromDoc)) return + case "multi-otp": + switch factorId { + case "invalid": + + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(invalidOTPStringFromDoc)) + return + default: + writeStatus(w, "SUCCESS") + return + } case "push-send-waiting": response := OktaApiPushResponseType{ Status: "MFA_CHALLENGE", @@ -91,6 +109,24 @@ func factorAuthnHandler(w http.ResponseWriter, req *http.Request) { case "push-send-accept": writeStatus(w, "SUCCESS") return + case "push-send-multi": + switch factorId { + case "success": + log.Printf("multi success!") + writeStatus(w, "SUCCESS") + return + default: + response := OktaApiPushResponseType{ + Status: "MFA_CHALLENGE", + FactorResult: "WAITING", + } + encoder := json.NewEncoder(w) + + if err := encoder.Encode(response); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } + return case "push-send-timeout": response := OktaApiPushResponseType{ Status: "MFA_CHALLENGE", @@ -352,7 +388,7 @@ func TestMfaOTPSuccess(t *testing.T) { Embedded: OktaApiEmbeddedDataResponseType{ Factor: []OktaApiMFAFactorsType{ OktaApiMFAFactorsType{ - Id: "someid", + Id: "validId", FactorType: "token:software:totp", VendorName: "OKTA"}, }}, @@ -371,6 +407,40 @@ func TestMfaOTPSuccess(t *testing.T) { } } +func TestMfaMutliOTPSuccess(t *testing.T) { + pa := &PasswordAuthenticator{authnURL: authnURL, + recentAuth: make(map[string]authCacheData), + logger: testlogger.New(t), + } + response := OktaApiPrimaryResponseType{ + StateToken: "valid-otp", + Status: "MFA_REQUIRED", + Embedded: OktaApiEmbeddedDataResponseType{ + Factor: []OktaApiMFAFactorsType{ + OktaApiMFAFactorsType{ + Id: "invalid", + FactorType: "token:software:totp", + VendorName: "OKTA"}, + OktaApiMFAFactorsType{ + Id: "success", + FactorType: "token:software:totp", + VendorName: "OKTA"}, + }}, + } + expiredUserCachedData := authCacheData{expires: time.Now().Add(60 * time.Second), + response: response, + } + goodOTPUser := "goodOTPUserMulti" + pa.recentAuth[goodOTPUser] = expiredUserCachedData + valid, err := pa.ValidateUserOTP(goodOTPUser, 123456) + if err != nil { + t.Fatal(err) + } + if !valid { + t.Fatal("should have succeeded with good user") + } +} + func TestMfaPushNonExisting(t *testing.T) { setupServer() pa := &PasswordAuthenticator{authnURL: authnURL, @@ -468,6 +538,42 @@ func TestMfaPushAccept(t *testing.T) { } } +func TestMfaPushAcceptMulti(t *testing.T) { + setupServer() + pa := &PasswordAuthenticator{authnURL: authnURL, + recentAuth: make(map[string]authCacheData), + logger: testlogger.New(t), + } + response := OktaApiPrimaryResponseType{ + StateToken: "push-send-multi", + Status: "MFA_REQUIRED", + Embedded: OktaApiEmbeddedDataResponseType{ + Factor: []OktaApiMFAFactorsType{ + OktaApiMFAFactorsType{ + Id: "waiting", + FactorType: "push", + VendorName: "OKTA"}, + OktaApiMFAFactorsType{ + Id: "success", + FactorType: "push", + VendorName: "OKTA"}, + }}, + } + userCacheData := authCacheData{ + expires: time.Now().Add(60 * time.Second), + response: response, + } + username := "puhsUserAccept" + pa.recentAuth[username] = userCacheData + pushResult, err := pa.ValidateUserPush(username) + if err != nil { + t.Fatal(err) + } + if pushResult != PushResponseApproved { + t.Fatal("Was supposed to be approved") + } +} + func TestMfaPushTimeout(t *testing.T) { setupServer() pa := &PasswordAuthenticator{authnURL: authnURL, diff --git a/lib/authutil/authutil.go b/lib/authutil/authutil.go index 0d105b68..db53989d 100644 --- a/lib/authutil/authutil.go +++ b/lib/authutil/authutil.go @@ -125,7 +125,7 @@ func getLDAPConnection(u url.URL, timeoutSecs uint, rootCAs *x509.CertPool) (*ld timeout := time.Duration(time.Duration(timeoutSecs) * time.Second) start := time.Now() tlsConn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", hostnamePort, - &tls.Config{ServerName: server, RootCAs: rootCAs}) + &tls.Config{ServerName: server, RootCAs: rootCAs, MinVersion: tls.VersionTLS12}) if err != nil { errorTime := time.Since(start).Seconds() * 1000 log.Printf("connction failure for:%s (%s)(time(ms)=%v)", server, err.Error(), errorTime) diff --git a/lib/certgen/certgen.go b/lib/certgen/certgen.go index 599b2121..b75233e5 100644 --- a/lib/certgen/certgen.go +++ b/lib/certgen/certgen.go @@ -1,5 +1,5 @@ /* - Package certgen id set of utilities used to generate ssh certificates +Package certgen contains a set of utilities used to generate ssh certificates. */ package certgen @@ -22,9 +22,38 @@ import ( "os/exec" "time" + "github.com/Cloud-Foundations/golib/pkg/log" "golang.org/x/crypto/ssh" ) +const ( + extensionSoftLimit = 10 << 10 // 10 KiB + extensionHardLimit = 12 << 10 // 12 KiB +) + +// addExtraExtension will add an extra extension to a certificate template +// provided the size limit is not exceeded. +func addExtraExtension(template *x509.Certificate, extension *pkix.Extension, + name string, logger log.DebugLogger) { + if extension == nil { + return + } + totalExtensionSize := len(extension.Value) + for _, existingExtension := range template.ExtraExtensions { + totalExtensionSize += len(existingExtension.Value) + } + if totalExtensionSize > extensionHardLimit { + logger.Printf("%s extension for %s too large (%d), ignoring\n", + name, template.Subject.CommonName, name, totalExtensionSize) + return + } + if totalExtensionSize > extensionSoftLimit { + logger.Printf("warning: %s extension for %s is large: %d\n", + name, template.Subject.CommonName, name, totalExtensionSize) + } + template.ExtraExtensions = append(template.ExtraExtensions, *extension) +} + // GetUserPubKeyFromSSSD user authorized keys content based on the running sssd configuration func GetUserPubKeyFromSSSD(username string) (string, error) { cmd := exec.Command("/usr/bin/sss_ssh_authorizedkeys", username) @@ -45,7 +74,7 @@ func goCertToFileString(c ssh.Certificate, username string) (string, error) { } // gen_user_cert a username and key, returns a short lived cert for that user -func GenSSHCertFileString(username string, userPubKey string, signer ssh.Signer, host_identity string, duration time.Duration) (certString string, cert ssh.Certificate, err error) { +func GenSSHCertFileString(username string, userPubKey string, signer ssh.Signer, host_identity string, duration time.Duration, customExtensions map[string]string) (certString string, cert ssh.Certificate, err error) { userKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(userPubKey)) if err != nil { return "", cert, err @@ -60,7 +89,23 @@ func GenSSHCertFileString(username string, userPubKey string, signer ssh.Signer, return "", cert, err } serial := (currentEpoch << 32) | nBig.Uint64() - + // Here we add standard extensions + extensions := map[string]string{ + "permit-X11-forwarding": "", + "permit-agent-forwarding": "", + "permit-port-forwarding": "", + "permit-pty": "", + "permit-user-rc": "", + } + if customExtensions != nil { + for key, value := range customExtensions { + //safeguard for invalid definition + if key == "" { + continue + } + extensions[key] = value + } + } // The values of the permissions are taken from the default values used // by ssh-keygen cert = ssh.Certificate{ @@ -72,12 +117,8 @@ func GenSSHCertFileString(username string, userPubKey string, signer ssh.Signer, ValidAfter: currentEpoch, ValidBefore: expireEpoch, Serial: serial, - Permissions: ssh.Permissions{Extensions: map[string]string{ - "permit-X11-forwarding": "", - "permit-agent-forwarding": "", - "permit-port-forwarding": "", - "permit-pty": "", - "permit-user-rc": ""}}} + Permissions: ssh.Permissions{Extensions: extensions}, + } err = cert.SignCert(bytes.NewReader(cert.Marshal()), signer) if err != nil { @@ -96,16 +137,16 @@ func GenSSHCertFileStringFromSSSDPublicKey(userName string, signer ssh.Signer, h if err != nil { return "", cert, err } - return GenSSHCertFileString(userName, userPubKey, signer, hostIdentity, duration) + return GenSSHCertFileString(userName, userPubKey, signer, hostIdentity, duration, nil) } -/// X509 section +// X509 section func getPubKeyFromPem(pubkey string) (pub interface{}, err error) { block, rest := pem.Decode([]byte(pubkey)) if block == nil || block.Type != "PUBLIC KEY" { - err := errors.New(fmt.Sprintf("Cannot decode user public Key '%s' rest='%s'", pubkey, string(rest))) + err := fmt.Errorf("Cannot decode user public Key '%s' rest='%s'", pubkey, string(rest)) if block != nil { - err = errors.New(fmt.Sprintf("public key bad type %s", block.Type)) + err = fmt.Errorf("public key bad type %s", block.Type) } return nil, err } @@ -159,7 +200,7 @@ func GetSignerFromPEMBytes(privateKey []byte) (crypto.Signer, error) { } } -//copied from https://golang.org/src/crypto/tls/generate_cert.go +// copied from https://golang.org/src/crypto/tls/generate_cert.go func publicKey(priv interface{}) interface{} { switch k := priv.(type) { case *rsa.PrivateKey: @@ -320,7 +361,7 @@ func genSANExtension(userName string, kerberosRealm *string) (*pkix.Extension, e return &sanExtension, nil } -func getGroupListExtension(groups []string) (*pkix.Extension, error) { +func makeGroupListExtension(groups []string) (*pkix.Extension, error) { if len(groups) < 1 { return nil, nil } @@ -336,13 +377,31 @@ func getGroupListExtension(groups []string) (*pkix.Extension, error) { return &groupListExtension, nil } +func makeServiceMethodListExtension(serviceMethods []string) ( + *pkix.Extension, error) { + if len(serviceMethods) < 1 { + return nil, nil + } + encodedValue, err := asn1.Marshal(serviceMethods) + if err != nil { + return nil, err + } + serviceMethodListExtension := pkix.Extension{ + // See github.com/Cloud-Foundations/Dominator/lib/constants.PermittedMethodListOID + Id: []int{1, 3, 6, 1, 4, 1, 9586, 100, 7, 1}, + Value: encodedValue, + } + return &serviceMethodListExtension, nil +} + // returns an x509 cert that has the username in the common name, // optionally if a kerberos Realm is present it will also add a kerberos // SAN exention for pkinit func GenUserX509Cert(userName string, userPub interface{}, caCert *x509.Certificate, caPriv crypto.Signer, kerberosRealm *string, duration time.Duration, - groups []string, organizations []string) ([]byte, error) { + groups, organizations, serviceMethods []string, + logger log.DebugLogger) ([]byte, error) { //// Now do the actual work... notBefore := time.Now() notAfter := notBefore.Add(duration) @@ -365,7 +424,12 @@ func GenUserX509Cert(userName string, userPub interface{}, CommonName: userName, Organization: organizations, } - groupListExtension, err := getGroupListExtension(groups) + groupListExtension, err := makeGroupListExtension(groups) + if err != nil { + return nil, err + } + serviceMethodListExtension, err := makeServiceMethodListExtension( + serviceMethods) if err != nil { return nil, err } @@ -380,14 +444,10 @@ func GenUserX509Cert(userName string, userPub interface{}, BasicConstraintsValid: true, IsCA: false, } - if groupListExtension != nil { - template.ExtraExtensions = append(template.ExtraExtensions, - *groupListExtension) - } - if sanExtension != nil { - template.ExtraExtensions = append(template.ExtraExtensions, - *sanExtension) - } + addExtraExtension(&template, groupListExtension, "group list", logger) + addExtraExtension(&template, serviceMethodListExtension, "service methods", + logger) + addExtraExtension(&template, sanExtension, "Kerberos SAN", logger) return x509.CreateCertificate(rand.Reader, &template, caCert, userPub, caPriv) } diff --git a/lib/certgen/certgen_test.go b/lib/certgen/certgen_test.go index 9a25c09c..882b2d60 100644 --- a/lib/certgen/certgen_test.go +++ b/lib/certgen/certgen_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/Cloud-Foundations/Dominator/lib/x509util" + "github.com/Cloud-Foundations/golib/pkg/log/testlogger" "golang.org/x/crypto/ssh" ) @@ -146,7 +147,7 @@ DhV+rrj+h1k9EaIv+VSQ98XGm97NK3PEkolWk5UngF3Qwt5qPDeGjpf4zyhej0lF KwIBAw== -----END PUBLIC KEY-----` -//now other valid sshKeys : ssh-keygen -t ecdsa +// now other valid sshKeys : ssh-keygen -t ecdsa const ecdsaPublicSSH = `ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBD+IdwZ/LsQhxE3soSMoCNOtqftjUgMoy7nqAukSL9MuULIbspoWRvF/bxDaaJf9dcz+mK/ILC5NXxNs36oYNOs= cviecco@cviecco--MacBookPro15` const ed25519PublicSSH = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDdNbfR67CJ0/iB5a5lQfZowi3VTrkDu7/rpMNKfHFPs cviecco@cviecco--MacBookPro15` @@ -200,7 +201,7 @@ RBm1g0vfLOjV1tPs5/0QMy7ANExMLGtzIJidWWWzIzw2rx4WC7xcIkJ+iWFIIFNy S9RSPfwJS7+Zr8LP4H6APpstQWZEXOo= -----END EC PRIVATE KEY-----` -//openssl genpkey -algorithm ED25519 -out key.pem +// openssl genpkey -algorithm ED25519 -out key.pem const pkcs8Ed25519PrivateKey = `-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEIHoHbl2RwHwmyWtXVLroUZEI+d/SqL3RKmECM5P7o7D5 -----END PRIVATE KEY-----` @@ -251,7 +252,7 @@ func TestGenSSHCertFileStringGenerateSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - certString, cert, err := GenSSHCertFileString(username, testUserPublicKey, goodSigner, hostIdentity, testDuration) + certString, cert, err := GenSSHCertFileString(username, testUserPublicKey, goodSigner, hostIdentity, testDuration, nil) if err != nil { t.Fatal(err) } @@ -267,7 +268,7 @@ func TestGenSSHCertFileStringGenerateSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - certString, cert, err = GenSSHCertFileString(username, ed25519PublicSSH, goodEd25519Signer, hostIdentity, testDuration) + certString, cert, err = GenSSHCertFileString(username, ed25519PublicSSH, goodEd25519Signer, hostIdentity, testDuration, nil) if err != nil { t.Fatal(err) } @@ -278,6 +279,32 @@ func TestGenSSHCertFileStringGenerateSuccess(t *testing.T) { if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != username { t.Fatal("invalid cert content, bad username") } + // test with non nil custom extensions: + extensionTest1 := map[string]string{"hello": "world"} + _, cert, err = GenSSHCertFileString(username, ed25519PublicSSH, goodEd25519Signer, hostIdentity, testDuration, extensionTest1) + if err != nil { + t.Fatal(err) + } + found := false + for key, value := range cert.Permissions.Extensions { + if key == "hello" { + found = true + if value != "world" { + t.Fatal("extension value is invalid") + } + break + } + } + if !found { + t.Fatal("custom extension not found") + } + // invalid extension blank name.. should NOT fail + invalidExtensionTest := map[string]string{"": "world"} + _, _, err = GenSSHCertFileString(username, ed25519PublicSSH, goodEd25519Signer, hostIdentity, testDuration, invalidExtensionTest) + if err != nil { + t.Fatal(err) + } + } func TestGenSSHCertFileStringGenerateFailBadPublicKey(t *testing.T) { @@ -287,7 +314,7 @@ func TestGenSSHCertFileStringGenerateFailBadPublicKey(t *testing.T) { if err != nil { t.Fatal(err) } - _, _, err = GenSSHCertFileString(username, "ThisIsNOTAPublicKey", goodSigner, hostIdentity, testDuration) + _, _, err = GenSSHCertFileString(username, "ThisIsNOTAPublicKey", goodSigner, hostIdentity, testDuration, nil) if err == nil { t.Fatal(err) } @@ -451,12 +478,13 @@ func derBytesCertToCertAndPem(derBytes []byte) (*x509.Certificate, string, error return cert, pemCert, nil } -//GenUserX509Cert(userName string, userPubkey string, caCertString string, caPrivateKeyString string) +// GenUserX509Cert(userName string, userPubkey string, caCertString string, caPrivateKeyString string) func TestGenUserX509CertGoodNoRealm(t *testing.T) { userPub, caCert, caPriv := setupX509Generator(t) groups := []string{"group0", "group1"} - derCert, err := GenUserX509Cert("username", userPub, caCert, caPriv, nil, testDuration, groups, nil) + derCert, err := GenUserX509Cert("username", userPub, caCert, caPriv, nil, + testDuration, groups, nil, nil, testlogger.New(t)) if err != nil { t.Fatal(err) } @@ -492,7 +520,8 @@ func TestGenx509CertGoodWithRealm(t *testing.T) { /* */ realm := "EXAMPLE.COM" - derCert, err := GenUserX509Cert("username", userPub, caCert, caPriv, &realm, testDuration, nil, nil) + derCert, err := GenUserX509Cert("username", userPub, caCert, caPriv, &realm, + testDuration, nil, nil, nil, testlogger.New(t)) if err != nil { t.Fatal(err) } @@ -509,7 +538,7 @@ func TestGenx509CertGoodWithRealm(t *testing.T) { // 6. kerberos realm info! } -//GenSelfSignedCACert +// GenSelfSignedCACert func TestGenSelfSignedCACertGood(t *testing.T) { caPriv, err := GetSignerFromPEMBytes([]byte(testSignerPrivateKey)) if err != nil { @@ -531,7 +560,8 @@ func TestGenSelfSignedCACertGood(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = GenUserX509Cert("username", userPub, cert, caPriv, nil, testDuration, nil, nil) + _, err = GenUserX509Cert("username", userPub, cert, caPriv, nil, + testDuration, nil, nil, nil, testlogger.New(t)) if err != nil { t.Fatal(err) } diff --git a/lib/certgen/iprestricted.go b/lib/certgen/iprestricted.go index ad73806e..46f75c97 100644 --- a/lib/certgen/iprestricted.go +++ b/lib/certgen/iprestricted.go @@ -4,7 +4,7 @@ import ( "bytes" "crypto" "crypto/rand" - "crypto/sha1" + "crypto/sha1" //#nosec G505 "crypto/x509" "crypto/x509/pkix" "encoding/asn1" @@ -109,7 +109,8 @@ func ComputePublicKeyKeyID(PublicKey interface{}) ([]byte, error) { return nil, err } - pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) + // sha1 is weak but that is the definition on the RFC + pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) //#nosec G401 return pubHash[:], nil } @@ -145,7 +146,7 @@ func GenIPRestrictedX509Cert(userName string, userPub interface{}, IssuingCertificateURL: crlURL, OCSPServer: OCPServer, BasicConstraintsValid: true, - IsCA: false, + IsCA: false, } if ipDelegationExtension != nil { template.ExtraExtensions = append(template.ExtraExtensions, diff --git a/lib/client/aws_role/api.go b/lib/client/aws_role/api.go new file mode 100644 index 00000000..b29aaa3f --- /dev/null +++ b/lib/client/aws_role/api.go @@ -0,0 +1,112 @@ +/* +Package aws_role may be used by service code to obtain Keymaster-issued identity +certificates. The identity certificate will contain the AWS IAM role that the +service code is able to assume (i.e. EC2 instance profile, EKS IRSA, Lambda +role). The full AWS Role ARN is stored in a certificate URI SAN extension and a +simplified form of the ARN is stored in the certificate CN. + +The full AWS Role ARN will reflect the actual role ARN, rather than an ARN +showing how the credentials were obtained. This mirrors the way AWS policy +documents are written. The ARN will have the form: +arn:aws:iam::$AccountId:role/$RoleName + +The service code does not require any extra permissions. It uses the +sts:GetCallerIdentity permission that is available to all AWS identities. Thus, +no policy configuration is required. + +This code uses the AWS IAM credentials to request a pre-signed URL from the AWS +Security Token Service (STS). This pre-signed URL is passed to Keymaster which +can make a request using the URL to verify the identity of the caller. No +credentials are sent. + +The protocol is a simple HTTPS/REST interface, typically located on the path: +/aws/requestRoleCertificate/v1 +The client issues a POST request with the following headers: + +Claimed-Arn: the full AWS Role ARN +Presigned-Method: the method type specified in the pre-signing response +Presigned-URL: the URL specified in the pre-signing response + +The body of the request must contain a PEM-encoded Public Key DER. +On success, the response body will contain a signed, PEM-encoded X.509 +Certificate. +*/ +package aws_role + +import ( + "context" + "crypto" + "crypto/tls" + "net/http" + "sync" + + "github.com/Cloud-Foundations/golib/pkg/awsutil/presignauth/presigner" + "github.com/Cloud-Foundations/golib/pkg/log" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +type Params struct { + // Required parameters. + KeymasterServer string + Logger log.DebugLogger + // Optional parameters. + AwsConfig *aws.Config + Context context.Context + HttpClient *http.Client + Signer crypto.Signer + StsClient *sts.Client + StsPresignClient *sts.PresignClient + derPubKey []byte + isSetup bool + pemPubKey []byte + presigner presigner.Presigner +} + +type Manager struct { + params Params + mutex sync.RWMutex // Protect everything below. + certError error + certPEM []byte + certTLS *tls.Certificate + waiters map[chan<- struct{}]struct{} +} + +// GetRoleCertificate requests an AWS role identify certificate from the +// Keymaster server specified in params. It returns the certificate PEM. +func GetRoleCertificate(params Params) ([]byte, error) { + return params.getRoleCertificate() +} + +// GetRoleCertificateTLS requests an AWS role identify certificate from the +// Keymaster server specified in params. It returns the certificate. +func GetRoleCertificateTLS(params Params) (*tls.Certificate, error) { + _, certTLS, err := params.getRoleCertificateTLS() + return certTLS, err +} + +// NewManager returns a certificate manager which provides AWS role identity +// certificates from the Keymaster server specified in params. Certificates +// are refreshed in the background. +func NewManager(params Params) (*Manager, error) { + return newManager(params) +} + +// GetClientCertificate returns a valid, cached certificate. The method +// value may be assigned to the crypto/tls.Config.GetClientCertificate field. +func (m *Manager) GetClientCertificate(cri *tls.CertificateRequestInfo) ( + *tls.Certificate, error) { + return m.getClientCertificate(cri) +} + +// GetRoleCertificate returns a valid, cached certificate. It returns the +// certificate PEM, TLS certificate and error. +func (m *Manager) GetRoleCertificate() ([]byte, *tls.Certificate, error) { + return m.getRoleCertificate() +} + +// WaitForRefresh waits until a successful certificate refresh. +func (m *Manager) WaitForRefresh() { + m.waitForRefresh() +} diff --git a/lib/client/aws_role/impl.go b/lib/client/aws_role/impl.go new file mode 100644 index 00000000..36c8d0c5 --- /dev/null +++ b/lib/client/aws_role/impl.go @@ -0,0 +1,203 @@ +package aws_role + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/Cloud-Foundations/golib/pkg/awsutil/presignauth/presigner" + + "github.com/Cloud-Foundations/keymaster/lib/paths" +) + +const rsaKeySize = 2048 + +func newManager(p Params) (*Manager, error) { + certPEM, certTLS, err := p.getRoleCertificateTLS() + if err != nil { + return nil, err + } + p.Logger.Printf("got AWS Role certificate for: %s\n", + p.presigner.GetCallerARN()) + manager := &Manager{ + params: p, + certPEM: certPEM, + certTLS: certTLS, + waiters: make(map[chan<- struct{}]struct{}), + } + go manager.refreshLoop() + return manager, nil +} + +func (m *Manager) getClientCertificate(cri *tls.CertificateRequestInfo) ( + *tls.Certificate, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + return m.certTLS, m.certError +} + +func (m *Manager) getRoleCertificate() ([]byte, *tls.Certificate, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + return m.certPEM, m.certTLS, m.certError +} + +func (m *Manager) refreshLoop() { + for ; ; time.Sleep(time.Minute) { + m.refreshOnce() + } +} + +func (m *Manager) refreshOnce() { + if m.certTLS != nil { + refreshTime := m.certTLS.Leaf.NotBefore.Add( + m.certTLS.Leaf.NotAfter.Sub(m.certTLS.Leaf.NotBefore) * 3 / 4) + duration := time.Until(refreshTime) + m.params.Logger.Debugf(1, "sleeping: %s before refresh\n", + (duration + time.Millisecond*50).Truncate(time.Millisecond*100)) + time.Sleep(duration) + } + if certPEM, certTLS, err := m.params.getRoleCertificateTLS(); err != nil { + m.params.Logger.Println(err) + if m.certTLS == nil { + m.mutex.Lock() + m.certError = err + m.mutex.Unlock() + } + } else { + m.mutex.Lock() + m.certError = nil + m.certPEM = certPEM + m.certTLS = certTLS + for waiter := range m.waiters { + select { + case waiter <- struct{}{}: + default: + } + delete(m.waiters, waiter) + } + m.mutex.Unlock() + m.params.Logger.Printf("refreshed AWS Role certificate for: %s\n", + m.params.presigner.GetCallerARN()) + } +} + +func (m *Manager) waitForRefresh() { + ch := make(chan struct{}, 1) + m.mutex.Lock() + m.waiters[ch] = struct{}{} + m.mutex.Unlock() + <-ch +} + +// Returns certificate PEM block. +func (p *Params) getRoleCertificate() ([]byte, error) { + if err := p.setupVerify(); err != nil { + return nil, err + } + presignedReq, err := p.presigner.PresignGetCallerIdentity(p.Context) + if err != nil { + return nil, err + } + p.Logger.Debugf(2, "presigned URL: %v\n", presignedReq.URL) + hostPath := p.KeymasterServer + paths.RequestAwsRoleCertificatePath + body := &bytes.Buffer{} + body.Write(p.pemPubKey) + req, err := http.NewRequestWithContext(p.Context, "POST", hostPath, body) + if err != nil { + return nil, err + } + req.Header.Add("claimed-arn", p.presigner.GetCallerARN().String()) + req.Header.Add("presigned-method", presignedReq.Method) + req.Header.Add("presigned-url", presignedReq.URL) + resp, err := p.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("got error from call %s, url='%s'\n", + resp.Status, hostPath) + } + return ioutil.ReadAll(resp.Body) +} + +// Returns certificate PEM block, TLS certificate and error. +func (p *Params) getRoleCertificateTLS() ([]byte, *tls.Certificate, error) { + certPEM, err := p.getRoleCertificate() + if err != nil { + return nil, nil, err + } + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, nil, fmt.Errorf("unable to decode certificate PEM block") + } + if block.Type != "CERTIFICATE" { + return nil, nil, fmt.Errorf("invalid certificate type: %s", block.Type) + } + x509Cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, err + } + return certPEM, + &tls.Certificate{ + Certificate: [][]byte{block.Bytes}, + PrivateKey: p.Signer, + Leaf: x509Cert, + }, + nil +} + +func (p *Params) setupVerify() error { + if p.isSetup { + return nil + } + if p.KeymasterServer == "" { + return fmt.Errorf("no keymaster server specified") + } + if p.Logger == nil { + return fmt.Errorf("no logger specified") + } + if p.Context == nil { + p.Context = context.TODO() + } + if p.HttpClient == nil { + p.HttpClient = http.DefaultClient + } + if p.Signer == nil { + signer, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + return err + } + p.Signer = signer + } + derPubKey, err := x509.MarshalPKIXPublicKey(p.Signer.Public()) + if err != nil { + return err + } + p.derPubKey = derPubKey + p.pemPubKey = pem.EncodeToMemory(&pem.Block{ + Bytes: p.derPubKey, + Type: "PUBLIC KEY", + }) + p.presigner, err = presigner.New(presigner.Params{ + AwsConfig: p.AwsConfig, + Logger: p.Logger, + StsClient: p.StsClient, + StsPresignClient: p.StsPresignClient, + }) + if err != nil { + return err + } + p.isSetup = true + return nil +} diff --git a/lib/client/config/api.go b/lib/client/config/api.go index 0befc80a..4c039364 100644 --- a/lib/client/config/api.go +++ b/lib/client/config/api.go @@ -7,10 +7,12 @@ import ( ) type BaseConfig struct { - Gen_Cert_URLS string `yaml:"gen_cert_urls"` - Username string `yaml:"username"` - FilePrefix string `yaml:"file_prefix"` - AddGroups bool `yaml:"add_groups"` + Gen_Cert_URLS string `yaml:"gen_cert_urls"` + Username string `yaml:"username"` + FilePrefix string `yaml:"file_prefix"` + AddGroups bool `yaml:"add_groups"` + WebauthBrowser string `yaml:"webauth_browser"` + AgentConfirmUse bool `yaml:"agent_confirm_use"` } // AppConfigFile represents a keymaster client configuration file diff --git a/lib/client/sshagent/agent.go b/lib/client/sshagent/agent.go index a76287a5..ea87a8ed 100644 --- a/lib/client/sshagent/agent.go +++ b/lib/client/sshagent/agent.go @@ -55,6 +55,7 @@ func upsertCertIntoAgent( privateKey interface{}, comment string, lifeTimeSecs uint32, + confirmBeforeUse bool, logger log.Logger) error { pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certText) if err != nil { @@ -65,6 +66,20 @@ func upsertCertIntoAgent( if !ok { return fmt.Errorf("It is not a certificate") } + keyToAdd := agent.AddedKey{ + PrivateKey: privateKey, + Certificate: sshCert, + Comment: comment, + ConfirmBeforeUse: confirmBeforeUse, + } + return withAddedKeyUpsertCertIntoAgent(keyToAdd, logger) +} + +func withAddedKeyUpsertCertIntoAgent(certToAdd agent.AddedKey, logger log.Logger) error { + if certToAdd.Certificate == nil { + return fmt.Errorf("Needs a certificate to be added") + } + conn, err := connectToDefaultSSHAgentLocation() if err != nil { return err @@ -73,22 +88,17 @@ func upsertCertIntoAgent( agentClient := agent.NewClient(conn) //delete certs in agent with the same comment - _, err = deleteDuplicateEntries(comment, agentClient, logger) + _, err = deleteDuplicateEntries(certToAdd.Comment, agentClient, logger) if err != nil { logger.Printf("failed during deletion err=%s", err) return err } - - keyToAdd := agent.AddedKey{ - PrivateKey: privateKey, - Certificate: sshCert, - Comment: comment, - } // NOTE: Current Windows ssh (OpenSSH_for_Windows_7.7p1, LibreSSL 2.6.5) // barfs when encountering a lifetime so we only add it for non-windows - if runtime.GOOS != "windows" { - keyToAdd.LifetimeSecs = lifeTimeSecs + if runtime.GOOS == "windows" { + certToAdd.LifetimeSecs = 0 + certToAdd.ConfirmBeforeUse = false } - return agentClient.Add(keyToAdd) + return agentClient.Add(certToAdd) } diff --git a/lib/client/sshagent/api.go b/lib/client/sshagent/api.go index 600b220b..c17586a4 100644 --- a/lib/client/sshagent/api.go +++ b/lib/client/sshagent/api.go @@ -1,6 +1,8 @@ package sshagent import ( + "golang.org/x/crypto/ssh/agent" + "github.com/Cloud-Foundations/golib/pkg/log" ) @@ -10,5 +12,9 @@ func UpsertCertIntoAgent( comment string, lifeTimeSecs uint32, logger log.Logger) error { - return upsertCertIntoAgent(certText, privateKey, comment, lifeTimeSecs, logger) + return upsertCertIntoAgent(certText, privateKey, comment, lifeTimeSecs, false, logger) +} + +func WithAddedKeyUpsertCertIntoAgent(certToAdd agent.AddedKey, logger log.Logger) error { + return withAddedKeyUpsertCertIntoAgent(certToAdd, logger) } diff --git a/lib/client/twofa/twofa.go b/lib/client/twofa/twofa.go index 27db1fcc..e22945e2 100644 --- a/lib/client/twofa/twofa.go +++ b/lib/client/twofa/twofa.go @@ -24,6 +24,7 @@ import ( "github.com/Cloud-Foundations/keymaster/lib/client/twofa/u2f" "github.com/Cloud-Foundations/keymaster/lib/webapi/v0/proto" "github.com/flynn/u2f/u2fhid" // client side (interface with hardware) + "github.com/marshallbrekka/go-u2fhost" "golang.org/x/crypto/ssh" ) @@ -225,22 +226,47 @@ func authenticateUser( } // upgrade to u2f successful2fa := false + + // Linux support for the new library is not quite correct + // so for now we keep using the old library (pure u2f) + // for linux cli as default. Windows 10 and MacOS have been + // tested successfully. + // The env variable allows us to swap what library is used by + // default + useWebAuthh := true + if runtime.GOOS == "linux" { + useWebAuthh = false + } + if os.Getenv("KEYMASTER_USEALTU2FLIB") != "" { + useWebAuthh = !useWebAuthh + } if !skip2fa { if allowU2F { - devices, err := u2fhid.Devices() - if err != nil { - logger.Fatal(err) - return err - } - if len(devices) > 0 { - - err = u2f.DoU2FAuthenticate( + if useWebAuthh { + err = u2f.WithDevicesDoWebAuthnAuthenticate(u2fhost.Devices(), client, baseUrl, userAgentString, logger) if err != nil { - + logger.Printf("Error doing hid webathentication err=%s", err) return err } successful2fa = true + + } else { + devices, err := u2fhid.Devices() + if err != nil { + logger.Printf("could not open hid devices err=%s", err) + return err + } + if len(devices) > 0 { + + err = u2f.DoU2FAuthenticate( + client, baseUrl, userAgentString, logger) + if err != nil { + + return err + } + successful2fa = true + } } } diff --git a/lib/client/twofa/u2f/api.go b/lib/client/twofa/u2f/api.go index 0e7ef3de..3a3dba70 100644 --- a/lib/client/twofa/u2f/api.go +++ b/lib/client/twofa/u2f/api.go @@ -5,11 +5,12 @@ import ( "net/http" "github.com/Cloud-Foundations/golib/pkg/log" + "github.com/marshallbrekka/go-u2fhost" ) // CheckU2FDevices checks the U2F devices and terminates the application by // calling Fatal on the passed logger if the U2F devices cannot be read. -func CheckU2FDevices(logger log.Logger) { +func CheckU2FDevices(logger log.DebugLogger) { checkU2FDevices(logger) } @@ -21,3 +22,21 @@ func DoU2FAuthenticate( logger log.DebugLogger) error { return doU2FAuthenticate(client, baseURL, userAgentString, logger) } + +func WithDevicesDoU2FAuthenticate( + devices []*u2fhost.HidDevice, + client *http.Client, + baseURL string, + userAgentString string, + logger log.DebugLogger) error { + return withDevicesDoU2FAuthenticate(devices, client, baseURL, userAgentString, logger) +} + +func WithDevicesDoWebAuthnAuthenticate( + devices []*u2fhost.HidDevice, + client *http.Client, + baseURL string, + userAgentString string, + logger log.DebugLogger) error { + return withDevicesDoWebAuthnAuthenticate(devices, client, baseURL, userAgentString, logger) +} diff --git a/lib/client/twofa/u2f/u2f.go b/lib/client/twofa/u2f/u2f.go index 3908272c..c4bffa54 100644 --- a/lib/client/twofa/u2f/u2f.go +++ b/lib/client/twofa/u2f/u2f.go @@ -6,20 +6,60 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "io/ioutil" "net/http" + "net/url" + "runtime" + "strings" "time" "github.com/Cloud-Foundations/golib/pkg/log" + "github.com/bearsh/hid" + "github.com/duo-labs/webauthn/protocol" "github.com/flynn/u2f/u2fhid" "github.com/flynn/u2f/u2ftoken" + "github.com/marshallbrekka/go-u2fhost" "github.com/tstranex/u2f" ) const clientDataAuthenticationTypeValue = "navigator.id.getAssertion" -func checkU2FDevices(logger log.Logger) { +type ClientData struct { + Typ string `json:"typ,omitempty"` + Type string `json:"type,omitempty"` + Challenge string `json:"challenge"` + ChannelIdPublicKey interface{} `json:"cid_pubkey,omitempty"` + Origin string `json:"origin"` +} + +/* +"response\":{\"authenticatorData\":\"criNDU5iGlmhNuL84SvhejdiYpVWbtvIehKuVx9kVfcBAAAAJA\",\"clientDataJSON\":\"eyJjaGFsbGVuZ2UiOiJxODM0dUFjdms4Z1lYSVljWDZ6V0NWSElzWHlzZHAwTVAydThaaWMtOTM0Iiwib3JpZ2luIjoiaHR0cHM6Ly9rZXltYXN0ZXIuc2VjLmNsb3VkLXN1cHBvcnQucHVyZXN0b3JhZ2UuY29tIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9\",\"signature\":\"MEUCIGw6WwBd2UupDnf24Qr9eEdBiYlN5ZHv4RBQScZVXCrrAiEApmRUz-H6Rk0ervDWDeQaoKZ9oITVlw8QwbZDDAdFmng\",\"userHandle\":\"\"} +*/ + +type AuthenticatorResponse struct { + AuthenticatorData string `json:"authenticatorData"` + ClientDataJSON string `json:"clientDataJSON"` + Signature string `json:"signature"` + UserHandle string `json:"userHandle"` +} + +/* + type WebAuthnAuthenticationResponse struct { + \"id\":\"bDtn39BgSSwOscXr3ruEGmegBVEd6yntysf8NiG2I2KDz7-CEiw9mIm1BvlQYfg9g1Rq38IpFwEj8Cxn_9uNlA\",\"rawId\":\"bDtn39BgSSwOscXr3ruEGmegBVEd6yntysf8NiG2I2KDz7-CEiw9mIm1BvlQYfg9g1Rq38IpFwEj8Cxn_9uNlA\",\"type\":\"public-key\",\"response\" +*/ +type WebAuthnAuthenticationResponse struct { + Id string `json:"id"` + RawId string `json:"rawId"` + Type string `json:"type"` + Response AuthenticatorResponse `json:"response"` +} + +var u2fHostTestUserPresenceError u2fhost.TestOfUserPresenceRequiredError +var u2fHostBadKeyHandleError u2fhost.BadKeyHandleError + +func checkU2FDevices(logger log.DebugLogger) { // TODO: move this to initialization code, ans pass the device list to this function? // or maybe pass the token?... devices, err := u2fhid.Devices() @@ -42,6 +82,23 @@ func checkU2FDevices(logger log.Logger) { defer dev.Close() } + // New listing + hidDevices := hid.Enumerate(0x0, 0x0) + logger.Printf("hid device len=%d", len(hidDevices)) + for i, device := range hidDevices { + logger.Debugf(1, "h2fHost hid device[%d]=%+v", i, device) + } + + devices2 := u2fhost.Devices() + for _, d2 := range devices2 { + logger.Printf("%+v", d2) + } + if len(devices2) == 0 { + logger.Fatal("no U2F (u2fHost) tokens found") + } else { + logger.Printf("u2fHost %d devices found", len(devices2)) + } + } func doU2FAuthenticate( @@ -154,6 +211,7 @@ func doU2FAuthenticate( } // Now we ask the token to sign/authenticate logger.Println("authenticating, provide user presence") + retryCount := 0 var rawBytes []byte for { res, err := t.Authenticate(req) @@ -161,6 +219,20 @@ func doU2FAuthenticate( time.Sleep(200 * time.Millisecond) continue } else if err != nil { + if runtime.GOOS == "darwin" && retryCount < 3 { + retryCount += 1 + if err.Error() == "hid: general error" || err.Error() == "hid: privilege violation" { + logger.Printf("retry on darwin general error") + // There is no t.Close() .. so we dot close and create a new one. + t = u2ftoken.NewToken(dev) + continue + } + if err.Error() == "u2fhid: received error from device: invalid message sequencing" { + logger.Printf("Error, message sequencing") + continue + } + + } logger.Fatal(err) } rawBytes = res.RawResponse @@ -203,3 +275,424 @@ func doU2FAuthenticate( io.Copy(ioutil.Discard, signRequestResp2.Body) return nil } + +func checkDeviceAuthSuccess(req *u2fhost.AuthenticateRequest, device u2fhost.Device, logger log.DebugLogger) (bool, error) { + timeout := time.After(time.Second * 3) + + interval := time.NewTicker(time.Millisecond * 250) + defer interval.Stop() + for { + select { + case <-timeout: + fmt.Println("Failed to get authentication response after 3 seconds") + return false, nil + case <-interval.C: + _, err := device.Authenticate(req) + if err == nil { + logger.Debugf(1, "device.Authenticate returned non error %s", err) + return true, nil + } + logger.Debugf(2, "Checker before exit Got status response %s", err) + switch err.Error() { + case u2fHostTestUserPresenceError.Error(): + return true, nil + case u2fHostBadKeyHandleError.Error(): + return false, nil + + default: + logger.Debugf(1, "Got status response %s", err) + } + } + } +} + +func authenticateHelper(req *u2fhost.AuthenticateRequest, devices []*u2fhost.HidDevice, keyHandles []string, logger log.DebugLogger) *u2fhost.AuthenticateResponse { + logger.Debugf(1, "Authenticating with request %+v", req) + openDevices := []u2fhost.Device{} + registeredDevices := make(map[u2fhost.AuthenticateRequest]u2fhost.Device) + for i, device := range devices { + err := device.Open() + if err == nil { + openDevices = append(openDevices, u2fhost.Device(devices[i])) + defer func(i int) { + devices[i].Close() + }(i) + // For each opened device we test if the handle is present + // It should be enough for u2f AND webauthn, but is not + // so we ned to add some logic for registered u2f devices + // Notice that each device is just cheked once with webauthn flow + // as prefered mechanism. + for _, handle := range keyHandles { + testReq := u2fhost.AuthenticateRequest{ + CheckOnly: true, + KeyHandle: handle, + AppId: req.AppId, + Facet: req.Facet, + Challenge: req.Challenge, + WebAuthn: req.WebAuthn, + } + copyReq := testReq + copyReq.CheckOnly = false + found, err := checkDeviceAuthSuccess(&testReq, device, logger) + if err != nil { + logger.Debugf(2, "authenticateHelper: skipping device due[%s] to error err=%s", handle, err) + continue + } + if !found { + if req.WebAuthn { + // Depending how some devices u2f devices we registered we need + // to sometimes (not clear yet,. TODO) to test the device using + // strict u2f logic and NO webauthn compatibility + testReq2 := u2fhost.AuthenticateRequest{ + CheckOnly: true, + KeyHandle: handle, + AppId: req.Facet, + Facet: req.Facet, + Challenge: req.Challenge, + WebAuthn: false, + } + copyReq := testReq2 + copyReq.CheckOnly = false + + found2, err2 := checkDeviceAuthSuccess(&testReq2, device, logger) + logger.Debugf(3, "authenticateHelper: Fallback check for %s: %v, %s", handle, found2, err2) + if found2 == true && err2 == nil { + logger.Debugf(3, "authenticateHelper: Fallback check success for device[%s]", handle) + registeredDevices[copyReq] = device + break + } + } + + logger.Debugf(2, "skipping device[%s] due to non error", handle) + continue + } + registeredDevices[copyReq] = device + break + } + version, err := device.Version() + if err != nil { + logger.Debugf(2, "Device version error: %s", err.Error()) + } else { + logger.Debugf(2, "Device version: %s", version) + } + } + } + logger.Debugf(2, " authenticateHelper: registeredDevices=%+v", registeredDevices) + + // Now we actually try to get users touch for devices that are found on the + // device list + if len(openDevices) == 0 { + logger.Fatalf("Failed to find any devices") + } + if len(registeredDevices) == 0 { + logger.Fatalf("No registered devices found") + } + prompted := false + timeout := time.After(time.Second * 25) + + interval := time.NewTicker(time.Millisecond * 250) + defer interval.Stop() + for { + select { + case <-timeout: + fmt.Println("Failed to get authentication response after 25 seconds") + return nil + case <-interval.C: + for handleReq, device := range registeredDevices { + response, err := device.Authenticate(&handleReq) + if err == nil { + logger.Debugf(1, "device.Authenticate retured non error %s", err) + return response + } else if err.Error() == u2fHostTestUserPresenceError.Error() && !prompted { + logger.Printf("\nTouch the flashing U2F device to authenticate...") + prompted = true + } else { + logger.Debugf(3, "Got status response %s", err) + } + } + } + } + return nil +} + +// This ensures the hostname matches...at this moment we do NOT check port number +// Port number should also be checked but leaving that out for now. +func verifyAppId(baseURLStr string, AppIdStr string) (bool, error) { + baseURL, err := url.Parse(baseURLStr) + if err != nil { + return false, err + } + baseURLHost, _, _ := strings.Cut(baseURL.Host, ":") + if AppIdStr == baseURL.Host || AppIdStr == baseURLHost { + return true, nil + } + // The base ID does not match... so we will now try to parse the appID + AppId, err := url.Parse(AppIdStr) + if err != nil { + return false, err + } + appIDHost, _, _ := strings.Cut(AppId.Host, ":") + if appIDHost == baseURLHost { + return true, nil + } + return false, nil +} + +func withDevicesDoU2FAuthenticate( + devices []*u2fhost.HidDevice, + client *http.Client, + baseURL string, + userAgentString string, + logger log.DebugLogger) error { + + logger.Debugf(2, "top of withDevicesDoU2fAuthenticate") + url := baseURL + "/u2f/SignRequest" + signRequest, err := http.NewRequest("GET", url, nil) + if err != nil { + logger.Fatal(err) + } + signRequest.Header.Set("User-Agent", userAgentString) + signRequestResp, err := client.Do(signRequest) // Client.Get(targetUrl) + if err != nil { + logger.Printf("Failure to sign request req %s", err) + return err + } + logger.Debugf(0, "Get url request did not failed %+v", signRequestResp) + // Dont defer the body response Close ... as we need to close it explicitly + // in the body of the function so that we can reuse the connection + if signRequestResp.StatusCode != 200 { + signRequestResp.Body.Close() + logger.Printf("got error from call %s, url='%s'\n", signRequestResp.Status, url) + err = errors.New("failed respose from sign request") + return err + } + var webSignRequest u2f.WebSignRequest + err = json.NewDecoder(signRequestResp.Body).Decode(&webSignRequest) + if err != nil { + logger.Fatal(err) + } + io.Copy(ioutil.Discard, signRequestResp.Body) + signRequestResp.Body.Close() + + var keyHandles []string + for _, registeredKey := range webSignRequest.RegisteredKeys { + keyHandles = append(keyHandles, registeredKey.KeyHandle) + } + + req := u2fhost.AuthenticateRequest{ + Challenge: webSignRequest.Challenge, + AppId: webSignRequest.AppID, // Provided by client or server + Facet: webSignRequest.AppID, //TODO: FIX this is actually Provided by client, so extract from baseURL + KeyHandle: webSignRequest.RegisteredKeys[0].KeyHandle, // TODO we should actually iterate over this? + } + deviceResponse := authenticateHelper(&req, devices, keyHandles, logger) + if deviceResponse == nil { + logger.Fatal("nil response from device?") + } + logger.Debugf(1, "signResponse authenticateHelper done") + + // Now we write the output data: + + webSignRequestBuf := &bytes.Buffer{} + err = json.NewEncoder(webSignRequestBuf).Encode(deviceResponse) + if err != nil { + logger.Fatal(err) + } + url = baseURL + "/u2f/SignResponse" + webSignRequest2, err := http.NewRequest("POST", url, webSignRequestBuf) + if err != nil { + logger.Printf("Failure to make http request") + return err + } + webSignRequest2.Header.Set("User-Agent", userAgentString) + signRequestResp2, err := client.Do(webSignRequest2) // Client.Get(targetUrl) + if err != nil { + logger.Printf("Failure to sign request req %s", err) + return err + } + defer signRequestResp2.Body.Close() + logger.Debugf(1, "signResponse request complete") + if signRequestResp2.StatusCode != 200 { + logger.Debugf(0, "got error from call %s, url='%s'\n", + signRequestResp2.Status, url) + return err + } + logger.Debugf(1, "signResponse success") + io.Copy(ioutil.Discard, signRequestResp2.Body) + return nil + +} + +func withDevicesDoWebAuthnAuthenticate( + devices []*u2fhost.HidDevice, + client *http.Client, + baseURL string, + userAgentString string, + logger log.DebugLogger) error { + + logger.Printf("top of withDevicesDoWebAutnfAuthenticate") + targetURL := baseURL + "/webauthn/AuthBegin/" // TODO: this should be grabbed from the webauthn definition as a const + signRequest, err := http.NewRequest("GET", targetURL, nil) + if err != nil { + logger.Fatal(err) + } + signRequest.Header.Set("User-Agent", userAgentString) + signRequestResp, err := client.Do(signRequest) // Client.Get(targetUrl) + if err != nil { + logger.Printf("Failure to sign request req %s", err) + return err + } + logger.Debugf(1, "Get url request did not failed %+v", signRequestResp) + // Dont defer the body response Close ... as we need to close it explicitly + // in the body of the function so that we can reuse the connection + if signRequestResp.StatusCode != 200 { + signRequestResp.Body.Close() + logger.Printf("got error from call %s, url='%s'\n", signRequestResp.Status, targetURL) + return fmt.Errorf("Failed response from remote sign request endpoint remote status=%s", signRequestResp.Status) + } + var credentialAssertion protocol.CredentialAssertion + err = json.NewDecoder(signRequestResp.Body).Decode(&credentialAssertion) + if err != nil { + logger.Fatal(err) + } + io.Copy(ioutil.Discard, signRequestResp.Body) + signRequestResp.Body.Close() + + logger.Debugf(2, "credential Assertion=%+v", credentialAssertion) + appId := credentialAssertion.Response.RelyingPartyID + if credentialAssertion.Response.Extensions != nil { + appIdIface, ok := credentialAssertion.Response.Extensions["appid"] + if ok { + extensionAppId, ok := appIdIface.(string) + if ok { + appId = extensionAppId + } + } + } + // TODO: add check on length of returned data + validAppId, err := verifyAppId(baseURL, appId) + if err != nil { + return err + } + if !validAppId { + return fmt.Errorf("Invalid AppId(escaped)=%s for base=%s", url.QueryEscape(appId), baseURL) + } + + var keyHandles []string + for _, credential := range credentialAssertion.Response.AllowedCredentials { + keyHandles = append(keyHandles, base64.RawURLEncoding.EncodeToString(credential.CredentialID)) + } + //keyHandle := base64.RawURLEncoding.EncodeToString(credentialAssertion.Response.AllowedCredentials[0].CredentialID) + + // + req := u2fhost.AuthenticateRequest{ + Challenge: credentialAssertion.Response.Challenge.String(), + Facet: appId, //TODO: FIX this is actually Provided by client, so or at least compere with base url host + AppId: credentialAssertion.Response.RelyingPartyID, // Provided by Server + //AppId: appId, + + KeyHandle: base64.RawURLEncoding.EncodeToString(credentialAssertion.Response.AllowedCredentials[0].CredentialID), + WebAuthn: true, + } + + deviceResponse := authenticateHelper(&req, devices, keyHandles, logger) + if deviceResponse == nil { + logger.Fatal("nil response from device?") + } + logger.Debugf(2, "signResponse authenticateHelper done") + + signature := deviceResponse.SignatureData + decodedSignature, err := base64.StdEncoding.DecodeString( + deviceResponse.SignatureData) + if err == nil { + signature = base64.RawURLEncoding.EncodeToString(decodedSignature) + } + authenticatorData := deviceResponse.AuthenticatorData + stringDecodedAuthenticatorData, err := base64.StdEncoding.DecodeString(deviceResponse.AuthenticatorData) + if err == nil { + authenticatorData = base64.RawURLEncoding.EncodeToString(stringDecodedAuthenticatorData) + } + // + var clientData ClientData + clientDataBytes, err := base64.RawURLEncoding.DecodeString(deviceResponse.ClientData) + if err != nil { + logger.Fatal("Cant base64 decode ClientData") + } + err = json.Unmarshal(clientDataBytes, &clientData) + if err != nil { + logger.Fatal("unmarshall clientData") + } + logger.Debugf(2, "clientData =%+v", clientData) + if clientData.Typ == clientDataAuthenticationTypeValue { + // The device signed data can be with the u2f protocol if compatibility + // is detected in that case we post on the u2f endpoint + webSignRequestBuf := &bytes.Buffer{} + err = json.NewEncoder(webSignRequestBuf).Encode(deviceResponse) + if err != nil { + logger.Fatal(err) + } + targetURL = baseURL + "/u2f/SignResponse" + webSignRequest2, err := http.NewRequest("POST", targetURL, webSignRequestBuf) + if err != nil { + logger.Printf("Failure to make http request") + return err + } + webSignRequest2.Header.Set("User-Agent", userAgentString) + signRequestResp2, err := client.Do(webSignRequest2) // Client.Get(targetUrl) + if err != nil { + logger.Printf("Failure to sign request req %s", err) + return err + } + defer signRequestResp2.Body.Close() + logger.Debugf(1, "signResponse request complete") + if signRequestResp2.StatusCode != 200 { + logger.Debugf(0, "got error from call %s, url='%s'\n", + signRequestResp2.Status, targetURL) + return err + } + logger.Debugf(1, "signResponse success") + io.Copy(ioutil.Discard, signRequestResp2.Body) + return nil + } + webResponse := WebAuthnAuthenticationResponse{ + Id: deviceResponse.KeyHandle, + RawId: deviceResponse.KeyHandle, + Type: "public-key", + Response: AuthenticatorResponse{ + AuthenticatorData: authenticatorData, + ClientDataJSON: deviceResponse.ClientData, + Signature: signature, + }, + } + + // Now we write the output data: + responseBytes, err := json.Marshal(webResponse) + if err != nil { + logger.Fatal(err) + } + logger.Debugf(3, "responseBytes=%s", string(responseBytes)) + webSignRequestBuf := bytes.NewReader(responseBytes) + + targetURL = baseURL + "/webauthn/AuthFinish/" + webSignRequest2, err := http.NewRequest("POST", targetURL, webSignRequestBuf) + if err != nil { + logger.Printf("Failure to make http request") + return err + } + webSignRequest2.Header.Set("User-Agent", userAgentString) + signRequestResp2, err := client.Do(webSignRequest2) // Client.Get(targetUrl) + if err != nil { + logger.Printf("Failure to sign request req %s", err) + return err + } + defer signRequestResp2.Body.Close() + logger.Debugf(2, "signResponse request complete") + if signRequestResp2.StatusCode != 200 { + logger.Debugf(1, "got error from call %s, url='%s'\n", + signRequestResp2.Status, targetURL) + return err + } + logger.Debugf(2, "signResponse resp=%+v", signRequestResp2) + io.Copy(ioutil.Discard, signRequestResp2.Body) + return nil +} diff --git a/lib/client/twofa/u2f/u2f_test.go b/lib/client/twofa/u2f/u2f_test.go new file mode 100644 index 00000000..26430a89 --- /dev/null +++ b/lib/client/twofa/u2f/u2f_test.go @@ -0,0 +1,51 @@ +package u2f + +import ( + "testing" +) + +func TestVerifyAppId(t *testing.T) { + passingData := map[string][]string{ + "https://good.example.com/": []string{ + "good.example.com", + "https://good.example.com/", + }, + "https://good.example.com:443/": []string{ + "good.example.com", + "https://good.example.com/", + }, + } + invalidAppid := map[string][]string{ + "https://good.example.com/": []string{ + "evil.example.com", + "https://evil.example.com/", + }, + "https://good.example.com:443/": []string{ + "evil.example.com", + "https://evil.example.com/", + }, + } + for baseURL, appIDList := range passingData { + for _, appId := range appIDList { + valid, err := verifyAppId(baseURL, appId) + if err != nil { + t.Fatal(err) + } + if !valid { + t.Fatalf("Falied to validate valid appId for base=%s, appid=%s", baseURL, appId) + } + } + } + for baseURL, appIDList := range invalidAppid { + for _, appId := range appIDList { + valid, err := verifyAppId(baseURL, appId) + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatalf("Falied to Invalidate invalid appId for base=%s, appid=%s", baseURL, appId) + } + } + } + +} diff --git a/lib/client/webauth/api.go b/lib/client/webauth/api.go new file mode 100644 index 00000000..e343a88d --- /dev/null +++ b/lib/client/webauth/api.go @@ -0,0 +1,43 @@ +package webauth + +import ( + "net/http" + "strings" + + "github.com/Cloud-Foundations/golib/pkg/log" +) + +type state struct { + // Parameters. + userName string + webauthBrowser []string + tokenFilename string + targetUrls []string + client *http.Client + userAgentString string + logger log.DebugLogger + // Runtime data. + gotCookie chan<- struct{} + portNumber string + tokenToWrite []byte +} + +// Authenticate will prompt the user to authenticate to a Keymaster server using +// a Web browser, for the specified username. +// The user will occasionally be prompted to copy-paste a token from the Web +// browser, which will be written to the file specified by tokenFilename. +// The authentication cookie will be saved in the client cookie jar which may be +// used for subsequent requests to sign identity certificates. +func Authenticate(userName, webauthBrowser, tokenFilename string, + targetUrls []string, client *http.Client, userAgentString string, + logger log.DebugLogger) (string, error) { + return authenticate(state{ + userName: userName, + webauthBrowser: strings.Fields(webauthBrowser), + tokenFilename: tokenFilename, + targetUrls: targetUrls, + client: client, + userAgentString: userAgentString, + logger: logger, + }) +} diff --git a/lib/client/webauth/impl.go b/lib/client/webauth/impl.go new file mode 100644 index 00000000..32780863 --- /dev/null +++ b/lib/client/webauth/impl.go @@ -0,0 +1,260 @@ +package webauth + +import ( + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/Cloud-Foundations/keymaster/lib/paths" + "golang.org/x/term" + "gopkg.in/square/go-jose.v2/jwt" +) + +const ( + authCookieName = "auth_cookie" + pathCloseTabRequest = "/closeTabRequest" +) + +type authInfoJWT struct { + Subject string `json:"sub,omitempty"` + Expiration int64 `json:"exp,omitempty"` +} + +func authenticate(s state) (string, error) { + // Fail early if token file cannot be written. + dirname := filepath.Dir(s.tokenFilename) + if err := os.MkdirAll(dirname, 0755); err != nil { + return "", err + } + gotCookie := make(chan struct{}, 1) + s.gotCookie = gotCookie + if err := s.startLocalServer(); err != nil { + return "", err + } + token, err := s.getToken() + if err != nil { + return "", err + } + if err := s.startAuthRequest(token); err != nil { + return "", err + } + timer := time.NewTimer(time.Minute) + select { + case <-gotCookie: + if !timer.Stop() { + <-timer.C + } + return s.targetUrls[0], nil + case <-timer.C: + return "", errors.New("timed out getting cookie") + } +} + +func parseToken(serialisedToken string) (*authInfoJWT, error) { + token, err := jwt.ParseSigned(serialisedToken) + if err != nil { + return nil, err + } + var data authInfoJWT + if err := token.UnsafeClaimsWithoutVerification(&data); err != nil { + return nil, err + } + return &data, nil +} + +func startCommand(cmd *exec.Cmd, timeout time.Duration) error { + errorChannel := make(chan error, 1) + timer := time.NewTimer(timeout) + go func(errorChannel chan<- error) { + errorChannel <- cmd.Run() + }(errorChannel) + select { + case err := <-errorChannel: + if !timer.Stop() { + <-timer.C + } + return err + case <-timer.C: + return nil + } +} + +func (s *state) closeTabRequestHandler(w http.ResponseWriter, + req *http.Request) { + w.Write([]byte(receiveAuthPageText)) +} + +func (s *state) getToken() (string, error) { + if token, err := s.readToken(); err != nil { + s.logger.Println(err) + } else if token != "" { + return token, nil + } + os.Remove(s.tokenFilename) // Delete a potentially poison cookie if present. + cmd := exec.Command(s.webauthBrowser[0], s.webauthBrowser[1:]...) + cmd.Args = append(cmd.Args, + fmt.Sprintf("%s%s?user=%s", s.targetUrls[0], paths.ShowAuthToken, + s.userName)) + if err := startCommand(cmd, time.Millisecond*200); err != nil { + return "", err + } + var token string + var inputData []byte + for { + fmt.Printf("Enter token: ") + var err error + inputData, err = term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", err + } + fmt.Println() + token = strings.TrimSpace(string(inputData)) + if _, err := parseToken(token); err != nil { + s.logger.Printf("Token appears invalid. Try again: %s\n", err) + continue + } + if err := s.verifyToken(token); err != nil { + s.logger.Printf("Unable to verify token. Try again: %s\n", err) + continue + } + break + } + s.tokenToWrite = inputData // Write later once fully verified. + return token, nil +} + +func (s *state) readToken() (string, error) { + fileData, err := ioutil.ReadFile(s.tokenFilename) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + token := strings.TrimSpace(string(fileData)) + parsedToken, err := parseToken(token) + if err != nil { + return "", err + } + if time.Until(time.Unix(parsedToken.Expiration, 0)) < 0 { + return "", nil + } + if err := s.verifyToken(token); err != nil { + return "", fmt.Errorf("unable to verify token: %s", err) + } + return token, nil +} + +func (s *state) receiveAuthHandler(w http.ResponseWriter, req *http.Request) { + s.logger.Debugln(1, "started receiveAuthHandler()") + if len(s.tokenToWrite) > 0 { // If we are here, Keymaster liked the token. + err := ioutil.WriteFile(s.tokenFilename, s.tokenToWrite, 0600) + if err != nil { + s.logger.Println(err) + } + } + // Fetch form/query data. + if err := req.ParseForm(); err != nil { + s.logger.Println(err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Error parsing form")) + return + } + var authCookieValue string + if val, ok := req.Form["auth_cookie"]; !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("No auth_cookie")) + return + } else { + if len(val) > 1 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Just one auth_cookie allowed")) + return + } + authCookieValue = val[0] + } + authCookie := &http.Cookie{Name: "auth_cookie", Value: authCookieValue} + for _, targetUrl := range s.targetUrls { + targetURL, err := url.Parse(targetUrl) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Error parsing URL")) + s.logger.Println(err) + return + } + s.client.Jar.SetCookies(targetURL, []*http.Cookie{authCookie}) + } + s.gotCookie <- struct{}{} + http.Redirect(w, req, pathCloseTabRequest, http.StatusPermanentRedirect) +} + +func (s *state) serve(listener net.Listener, serveMux *http.ServeMux) { + if err := http.Serve(listener, serveMux); err != nil { + panic(err) + } +} + +func (s *state) startAuthRequest(token string) error { + cmd := exec.Command(s.webauthBrowser[0], s.webauthBrowser[1:]...) + cmd.Args = append(cmd.Args, + fmt.Sprintf("%s%s?port=%s&user=%s&token=%s", + s.targetUrls[0], paths.SendAuthDocument, s.portNumber, s.userName, + token)) + return startCommand(cmd, time.Millisecond*200) +} + +func (s *state) startLocalServer() error { + listener, err := net.Listen("tcp", "localhost:") + if err != nil { + return err + } + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + return err + } + s.logger.Debugf(0, "listening on localhost:%s\n", port) + s.portNumber = port + serveMux := http.NewServeMux() + serveMux.HandleFunc(paths.ReceiveAuthDocument, s.receiveAuthHandler) + serveMux.HandleFunc(pathCloseTabRequest, s.closeTabRequestHandler) + go s.serve(listener, serveMux) + return nil +} + +func (s *state) verifyToken(token string) error { + resp, err := s.client.Get(fmt.Sprintf("%s%s?token=%s", + s.targetUrls[0], paths.VerifyAuthToken, token)) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + return nil + case http.StatusNotFound: + s.logger.Println("Token verification not supported") + return nil + } + if body, err := ioutil.ReadAll(resp.Body); err != nil { + return err + } else { + return errors.New(string(body)) + } +} + +const receiveAuthPageText = ` + + +

      Please close this tab

      + + +` diff --git a/lib/paths/api.go b/lib/paths/api.go new file mode 100644 index 00000000..2bbc20b1 --- /dev/null +++ b/lib/paths/api.go @@ -0,0 +1,9 @@ +package paths + +const ( + ReceiveAuthDocument = "/receiveAuthDocument" + RequestAwsRoleCertificatePath = "/aws/requestRoleCertificate/v1" + SendAuthDocument = "/sendAuthDocument" + ShowAuthToken = "/showAuthToken" + VerifyAuthToken = "/verifyAuthToken" +) diff --git a/lib/pwauth/htpassword/api.go b/lib/pwauth/htpassword/api.go new file mode 100644 index 00000000..1cb2d8ac --- /dev/null +++ b/lib/pwauth/htpassword/api.go @@ -0,0 +1,37 @@ +package htpassword + +import ( + "github.com/Cloud-Foundations/golib/pkg/log" + "github.com/Cloud-Foundations/keymaster/lib/pwauth" + "github.com/Cloud-Foundations/keymaster/lib/simplestorage" +) + +type PasswordAuthenticator struct { + filename string + logger log.DebugLogger +} + +// Static interface compatibility check. +var _ = pwauth.PasswordAuthenticator(&PasswordAuthenticator{}) + +// New creates a new PasswordAuthenticator. The htpassword file used to +// authenticate the user is filename. Log messages are written to logger. A new +// *PasswordAuthenticator is returned if the file exists, else an error is +// returned. +func New(filename string, + logger log.DebugLogger) (*PasswordAuthenticator, error) { + return newAuthenticator(filename, logger) +} + +// PasswordAuthenticate will authenticate a user using the provided username and +// password. +// It returns true if the user is authenticated, else false (due to either +// invalid username or incorrect password), and an error. +func (pa *PasswordAuthenticator) PasswordAuthenticate(username string, + password []byte) (bool, error) { + return pa.passwordAuthenticate(username, password) +} + +func (pa *PasswordAuthenticator) UpdateStorage(storage simplestorage.SimpleStore) error { + return nil +} diff --git a/lib/pwauth/htpassword/impl.go b/lib/pwauth/htpassword/impl.go new file mode 100644 index 00000000..51a2fcb2 --- /dev/null +++ b/lib/pwauth/htpassword/impl.go @@ -0,0 +1,34 @@ +package htpassword + +import ( + "fmt" + "io/ioutil" + "os" + + "github.com/Cloud-Foundations/golib/pkg/log" + "github.com/Cloud-Foundations/keymaster/lib/authutil" +) + +func newAuthenticator(filename string, + logger log.DebugLogger) (*PasswordAuthenticator, error) { + if fi, err := os.Stat(filename); err != nil { + return nil, err + } else if fi.Mode()&os.ModeType != 0 { + return nil, fmt.Errorf("%s is not a regular file", filename) + } + return &PasswordAuthenticator{ + filename: filename, + logger: logger, + }, nil +} + +func (pa *PasswordAuthenticator) passwordAuthenticate(username string, + password []byte) (bool, error) { + pa.logger.Debugf(3, "checking %s in htpassword file\n", username) + buffer, err := ioutil.ReadFile(pa.filename) + if err != nil { + return false, err + } + return authutil.CheckHtpasswdUserPassword(username, string(password), + buffer) +} diff --git a/lib/server/aws_identity_cert/api.go b/lib/server/aws_identity_cert/api.go new file mode 100644 index 00000000..dd02242a --- /dev/null +++ b/lib/server/aws_identity_cert/api.go @@ -0,0 +1,47 @@ +package aws_identity_cert + +import ( + "crypto/x509" + "net/http" + + presignc "github.com/Cloud-Foundations/golib/pkg/awsutil/presignauth/caller" + "github.com/Cloud-Foundations/golib/pkg/log" +) + +type Issuer struct { + presignCallerClient presignc.Caller + params Params +} + +type Params struct { + // Required parameters. + CertificateGenerator func(template *x509.Certificate, + publicKey interface{}) ([]byte, error) + // Optional parameters. + AccountIdValidator func(accountId string) bool + FailureWriter func(w http.ResponseWriter, r *http.Request, + errorString string, code int) + HttpClient *http.Client + Logger log.DebugLogger +} + +// New will create a certificate issuer for AWS IAM identity certificates. +func New(params Params) (*Issuer, error) { + return newIssuer(params) +} + +// RequestHandler implements a REST interface that will respond with a signed +// X.509 Certificate for a request with a pre-signed URL from the AWS +// Security Token Service (STS). This pre-signed URL is used to verify the +// identity of the caller. +// The request must contain the following headers: +// Claimed-Arn: the full AWS Role ARN +// Presigned-Method: the method type specified in the pre-signing response +// Presigned-URL: the URL specified in the pre-signing response +// The body of the request must contain a PEM-encoded Public Key DER. +// On success, the response body will contain a signed, PEM-encoded X.509 +// Certificate and the Certificate template is returned. +func (i *Issuer) RequestHandler(w http.ResponseWriter, + r *http.Request) *x509.Certificate { + return i.requestHandler(w, r) +} diff --git a/lib/server/aws_identity_cert/impl.go b/lib/server/aws_identity_cert/impl.go new file mode 100644 index 00000000..3b3e5387 --- /dev/null +++ b/lib/server/aws_identity_cert/impl.go @@ -0,0 +1,189 @@ +package aws_identity_cert + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "html" + "io/ioutil" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + presignc "github.com/Cloud-Foundations/golib/pkg/awsutil/presignauth/caller" + "github.com/Cloud-Foundations/golib/pkg/log/nulllogger" + + "github.com/aws/aws-sdk-go-v2/aws/arn" +) + +func defaultFailureWriter(w http.ResponseWriter, r *http.Request, + errorString string, code int) { + http.Error(w, errorString, code) +} + +func getCallerIdentity(header http.Header, + presignCallerClient presignc.Caller) (arn.ARN, error) { + claimedArn := html.EscapeString(header.Get("claimed-arn")) + presignedMethod := header.Get("presigned-method") + presignedUrl := header.Get("presigned-url") + if claimedArn == "" || presignedUrl == "" || presignedMethod == "" { + return arn.ARN{}, fmt.Errorf("missing presigned request data") + } + parsedArn, err := presignCallerClient.GetCallerIdentity(nil, + presignedMethod, presignedUrl) + if err != nil { + return arn.ARN{}, err + } + if parsedArn.String() != claimedArn { + return arn.ARN{}, fmt.Errorf("validated ARN: %s != claimed ARN: %s", + parsedArn.String(), claimedArn) + } + return parsedArn, nil +} + +func makeCertificateTemplate(callerArn arn.ARN) (*x509.Certificate, error) { + if !strings.HasPrefix(callerArn.Resource, "role/") { + return nil, fmt.Errorf("invalid resource: %s", callerArn.Resource) + } + commonName := roleCommonName(callerArn) + subject := pkix.Name{ + CommonName: commonName, + Organization: []string{"keymaster"}, + } + arnUrl, err := url.Parse(callerArn.String()) + if err != nil { + return nil, err + } + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, err + } + now := time.Now() + return &x509.Certificate{ + SerialNumber: serialNumber, + Subject: subject, + NotBefore: now, + NotAfter: now.Add(time.Hour * 24), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: false, + URIs: []*url.URL{arnUrl}, + }, nil +} + +func newIssuer(params Params) (*Issuer, error) { + if params.AccountIdValidator == nil { + params.AccountIdValidator = nullAccountIdValidator + } + if params.FailureWriter == nil { + params.FailureWriter = defaultFailureWriter + } + if params.Logger == nil { + params.Logger = nulllogger.New() + } + presignCallerClient, err := presignc.New(presignc.Params{ + HttpClient: params.HttpClient, + Logger: params.Logger, + }) + if err != nil { + return nil, err + } + return &Issuer{ + presignCallerClient: presignCallerClient, + params: params, + }, nil +} + +func nullAccountIdValidator(accountId string) bool { + return true +} + +func nullCertificateModifier(cert *x509.Certificate) error { + return nil +} + +func roleCommonName(roleArn arn.ARN) string { + return fmt.Sprintf("aws:iam:%s:%s", roleArn.AccountID, roleArn.Resource[5:]) +} + +func (i *Issuer) requestHandler(w http.ResponseWriter, + r *http.Request) *x509.Certificate { + if r.Method != "POST" { + i.params.FailureWriter(w, r, "", http.StatusMethodNotAllowed) + return nil + } + // First extract and validate AWS credentials claim. + callerArn, err := getCallerIdentity(r.Header, i.presignCallerClient) + if err != nil { + i.params.Logger.Println(err) + i.params.FailureWriter(w, r, "verification request failed", + http.StatusUnauthorized) + return nil + } + if !i.params.AccountIdValidator(callerArn.AccountID) { + i.params.Logger.Printf("AWS account: %s not allowed\n", + callerArn.AccountID) + i.params.FailureWriter(w, r, "AWS account not allowed", + http.StatusUnauthorized) + return nil + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + i.params.Logger.Println(err) + i.params.FailureWriter(w, r, "error reading body", + http.StatusInternalServerError) + return nil + } + // Now extract the public key PEM data. + block, _ := pem.Decode(body) + if block == nil { + i.params.Logger.Println("unable to decode PEM block") + i.params.FailureWriter(w, r, "invalid PEM block", http.StatusBadRequest) + return nil + } + if block.Type != "PUBLIC KEY" { + i.params.Logger.Printf("unsupported PEM type: %s\n", + html.EscapeString(block.Type)) + i.params.FailureWriter(w, r, "unsupported PEM type", + http.StatusBadRequest) + return nil + } + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + i.params.Logger.Println(err) + i.params.FailureWriter(w, r, "invalid DER", http.StatusBadRequest) + return nil + } + template, certDER, err := i.generateRoleCert(pub, callerArn) + if err != nil { + i.params.Logger.Println(err) + i.params.FailureWriter(w, r, err.Error(), + http.StatusInternalServerError) + return nil + } + pem.Encode(w, &pem.Block{Bytes: certDER, Type: "CERTIFICATE"}) + return template +} + +// Returns template and signed certificate DER. +func (i *Issuer) generateRoleCert(publicKey interface{}, + callerArn arn.ARN) (*x509.Certificate, []byte, error) { + template, err := makeCertificateTemplate(callerArn) + if err != nil { + return nil, nil, err + } + certDER, err := i.params.CertificateGenerator(template, publicKey) + if err != nil { + return nil, nil, err + } + i.params.Logger.Printf( + "Generated x509 Certificate for ARN=`%s`, expires=%s", + callerArn.String(), template.NotAfter) + return template, certDER, nil +} diff --git a/lib/server/aws_identity_cert/impl_test.go b/lib/server/aws_identity_cert/impl_test.go new file mode 100644 index 00000000..86bf58b7 --- /dev/null +++ b/lib/server/aws_identity_cert/impl_test.go @@ -0,0 +1,84 @@ +package aws_identity_cert + +import ( + "context" + "net/http" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws/arn" +) + +const ( + awsClaimedArnBad = "arn:aws:iam::accountid:aResource/bogus" + awsClaimedArnGood = "arn:aws:iam::accountid:role/TestMonkey" +) + +type testCallerType struct { + arn string +} + +func (c *testCallerType) GetCallerIdentity(ctx context.Context, + presignedMethod string, presignedUrl string) (arn.ARN, error) { + parsedArn, _ := arn.Parse(c.arn) + return parsedArn, nil +} + +func TestGetCallerIdentity(t *testing.T) { + header := make(http.Header) + header.Add("claimed-arn", awsClaimedArnBad) + header.Add("presigned-method", "GET") + header.Add("presigned-url", "https://some.website/") + parsedArn, err := getCallerIdentity(header, + &testCallerType{awsClaimedArnGood}) + if err == nil { + t.Errorf("no error with mismatched ARN") + } + header = make(http.Header) + header.Add("claimed-arn", awsClaimedArnGood) + header.Add("presigned-method", "GET") + header.Add("presigned-url", "https://some.website/") + parsedArn, err = getCallerIdentity(header, + &testCallerType{awsClaimedArnGood}) + if err != nil { + t.Fatal(err) + } + if parsedArn.String() != awsClaimedArnGood { + t.Errorf("expected: %s but got: %s", awsClaimedArnGood, parsedArn) + } +} + +func TestMakeCertificateTemplate(t *testing.T) { + callerArn, err := arn.Parse(awsClaimedArnBad) + if err != nil { + t.Fatal(err) + } + _, err = makeCertificateTemplate(callerArn) + if err == nil { + t.Errorf("no error with bad ARN: %s", awsClaimedArnBad) + } + callerArn, err = arn.Parse(awsClaimedArnGood) + if err != nil { + t.Fatal(err) + } + template, err := makeCertificateTemplate(callerArn) + if err != nil { + t.Error(err) + } + expected := roleCommonName(callerArn) + if template.Subject.CommonName != expected { + t.Errorf("expected common name: %s but got: %s", + expected, template.Subject.CommonName) + } +} + +func TestRoleCommonName(t *testing.T) { + callerArn, err := arn.Parse(awsClaimedArnGood) + if err != nil { + t.Fatal(err) + } + computed := roleCommonName(callerArn) + expected := "aws:iam:accountid:TestMonkey" + if computed != expected { + t.Errorf("expected common name: %s but got: %s", expected, computed) + } +} diff --git a/lib/util/util.go b/lib/util/util.go index 4a9ef9fb..05ed51bb 100644 --- a/lib/util/util.go +++ b/lib/util/util.go @@ -5,10 +5,42 @@ import ( "io" "log" "mime/multipart" + "net" "net/http" "strings" ) +func GetRequestRealIp(r *http.Request) string { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + if ip != "127.0.0.1" { + return ip + } + // Check if behind nginx or apache + xRealIP := r.Header.Get("X-Real-Ip") + xForwardedFor := r.Header.Get("X-Forwarded-For") + + for _, address := range strings.Split(xForwardedFor, ",") { + address = strings.TrimSpace(address) + if address != "" { + parsedAddress := net.ParseIP(address) + if parsedAddress != nil { + return parsedAddress.String() + } + } + } + + if xRealIP != "" { + parsedAddress := net.ParseIP(xRealIP) + if parsedAddress != nil { + return parsedAddress.String() + } + } + return ip +} + func CreateSimpleDataBodyRequest(method string, urlStr string, filebytes []byte, contentType string) (*http.Request, error) { bodyBuf := bytes.NewBuffer(filebytes) req, err := http.NewRequest(method, urlStr, bodyBuf) diff --git a/lib/webapi/v0/proto/api.go b/lib/webapi/v0/proto/api.go index 07a12a5d..8e6f5a06 100644 --- a/lib/webapi/v0/proto/api.go +++ b/lib/webapi/v0/proto/api.go @@ -11,6 +11,7 @@ const ( AuthTypeTOTP = "TOTP" AuthTypeOkta2FA = "Okta2FA" AuthTypeBootstrapOTP = "BootstrapOTP" + AuthTypeWebauthForCLI = "WebauthForCLI" ) type LoginResponse struct { diff --git a/misc/startup/keymaster-eventmond.service b/misc/startup/keymaster-eventmond.service index 0f4132dc..ab5098fa 100644 --- a/misc/startup/keymaster-eventmond.service +++ b/misc/startup/keymaster-eventmond.service @@ -7,7 +7,8 @@ ExecStart=/usr/local/sbin/keymaster-eventmond ExecReload=/bin/kill -HUP $MAINPID Restart=always RestartSec=1 -User=nobody +User=eventmon +Group=eventmon [Install] WantedBy=multi-user.target