diff --git a/docker/Dockerfile-ca b/docker/Dockerfile-ca index 749fcb5..406a420 100644 --- a/docker/Dockerfile-ca +++ b/docker/Dockerfile-ca @@ -1,38 +1,10 @@ -# This dockerfile builds a container capable of running the SSH CA bot. Note that a lot of this code is duplicated -# between this file and Dockerfile-kssh. -FROM ubuntu:18.04 - -# Dependencies -RUN apt-get -qq update -RUN apt-get -qq install curl software-properties-common ca-certificates gnupg -y -RUN useradd -ms /bin/bash keybase -USER keybase -WORKDIR /home/keybase - -# Download and verify the deb -# Key fingerprint from https://keybase.io/docs/server_security/our_code_signing_key -RUN curl --remote-name https://prerelease.keybase.io/keybase_amd64.deb -RUN curl --remote-name https://prerelease.keybase.io/keybase_amd64.deb.sig -# Import our gpg key from our website. Pulling from key servers caused a flakey build so -# we get the key from the Keybase website instead. -RUN curl -sSL https://keybase.io/docs/server_security/code_signing_key.asc | gpg --import -# This line will error if the fingerprint of the key in the file does not match the -# known fingerprint of the our PGP key -RUN gpg --fingerprint 222B85B0F90BE2D24CFEB93F47484E50656D16C7 -# And then verify the signature now that we have the key -RUN gpg --verify keybase_amd64.deb.sig keybase_amd64.deb - -# Silence the error from dpkg about failing to configure keybase since `apt-get install -f` fixes it -USER root -RUN dpkg -i keybase_amd64.deb || true -RUN apt-get install -fy -USER keybase +# See docker/Dockerfile-keybase +FROM keybase:latest # Install go USER root RUN add-apt-repository ppa:gophers/archive -y -RUN apt-get update -RUN apt-get install golang-1.11-go git sudo -y +RUN apt-get update && apt-get install golang-1.11-go git sudo -y USER keybase # Install go dependencies (speeds up future builds) diff --git a/docker/Dockerfile-keybase b/docker/Dockerfile-keybase new file mode 100644 index 0000000..8101259 --- /dev/null +++ b/docker/Dockerfile-keybase @@ -0,0 +1,26 @@ +# This dockerfile builds an Ubuntu container with Keybase installed +FROM ubuntu:18.04 + +# Dependencies +RUN apt-get -qq update && apt-get -qq install curl software-properties-common ca-certificates gnupg -y +RUN useradd -ms /bin/bash keybase +USER keybase +WORKDIR /home/keybase + +# Download and verify the deb +RUN curl --remote-name https://prerelease.keybase.io/keybase_amd64.deb +RUN curl --remote-name https://prerelease.keybase.io/keybase_amd64.deb.sig +# Import our gpg key from our website. Pulling from key servers caused a flakey build so +# we get the key from the Keybase website instead. +RUN curl -sSL https://keybase.io/docs/server_security/code_signing_key.asc | gpg --import +# This line will error if the fingerprint of the key in the file does not match the +# known fingerprint of the our PGP key +RUN gpg --fingerprint 222B85B0F90BE2D24CFEB93F47484E50656D16C7 +# And then verify the signature now that we have the key +RUN gpg --verify keybase_amd64.deb.sig keybase_amd64.deb + +# Silence the error from dpkg about failing to configure keybase since `apt-get install -f` fixes it +USER root +RUN dpkg -i keybase_amd64.deb || true +RUN apt-get install -fy +USER keybase diff --git a/docker/Makefile b/docker/Makefile index 28d169d..1695dd1 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -11,6 +11,7 @@ SHELL := /bin/bash # Build a new docker image for the CA bot build: reset-permissions + docker build -t keybase -f Dockerfile-keybase . docker build -t ca -f Dockerfile-ca .. # Generate a new CA key diff --git a/docs/env.md b/docs/env.md index e2e928c..884c134 100644 --- a/docs/env.md +++ b/docs/env.md @@ -106,6 +106,45 @@ export ANNOUNCEMENT="Hello! I'm {USERNAME} and I'm an SSH bot! I'm currently lis export ANNOUNCEMENT="Hello! I'm {USERNAME} and I'm an SSH bot! Being in {CURRENT_TEAM} will grant you SSH access to certain servers. Reach out to @dworken for more information." ``` +## M of N Realms + +It is possible to configure the SSH CA bot to require approval prior to granting access. For example, one could require +that in order to use kssh with servers in the `team.ssh.root_everywhere` realm two other people must approve the request. +Approvals are done by reacting with a :+1: emoji to the message inside of Keybase chat. + +In order to configure this, there are 3 environment variables that can be used: + +``` +export CTRL_POLICY_MOFN_TEAMS="team.ssh.root_everywhere, team.ssh.ci" +export CTRL_POLICY_MOFN_APPROVERS="username0, username1, username2" +export CTRL_POLICY_MOFN_REQUIRED_COUNT="2" +``` + +The above will require that anyone who wants to use kssh with `team.ssh.root_everywhere` or `team.ssh.ci` gets approval +from two other people. Only three people are configured to approve requests: `username0, username1, username2`. + +A few other example configurations are: + +``` +export CTRL_POLICY_MOFN_TEAMS="team.ssh.root_everywhere" +export CTRL_POLICY_MOFN_APPROVERS="username" +``` + +This would require that in order to use kssh with `team.ssh.root_everywhere` it must be approved by `username`. + +As a user, this would be done by running: + +``` +kssh --request-realm team.ssh.root_everywhere root@production +``` + +For a user, the standard commands would work for all other configured teams: + +``` +kssh dev@staging +kssh dev@prod +``` + ## Developer Options These environment variables are mainly useful for dev work. For security reasons, it is recommended always to run a diff --git a/integrationTest.sh b/integrationTest.sh index e062e72..29fdf50 100755 --- a/integrationTest.sh +++ b/integrationTest.sh @@ -47,7 +47,8 @@ cd tests/ reset_docker echo "Building containers..." -cd ../docker/ && make && cd ../tests/ +cd ../docker/ && make build && cd ../tests/ +pwd docker-compose build echo "Running integration tests..." docker-compose up -d diff --git a/src/cmd/kssh/kssh.go b/src/cmd/kssh/kssh.go index 0c45006..c4c8bbc 100644 --- a/src/cmd/kssh/kssh.go +++ b/src/cmd/kssh/kssh.go @@ -20,27 +20,27 @@ import ( func main() { kssh.InitLogging() - team, remainingArgs, action, err := handleArgs(os.Args[1:]) + botname, requestedPrincipal, remainingArgs, action, err := handleArgs(os.Args[1:]) if err != nil { fmt.Printf("Failed to parse arguments: %v\n", err) os.Exit(1) } - keyPath, err := getSignedKeyLocation(team) + keyPath, err := getSignedKeyLocation(botname) if err != nil { fmt.Printf("Failed to retrieve location to store SSH keys: %v\n", err) os.Exit(1) } - if isValidCert(keyPath) { + if isValidCert(keyPath) && requestedPrincipal == "" { log.WithField("keyPath", keyPath).Debug("Reusing unexpired certificate") doAction(action, keyPath, remainingArgs) os.Exit(0) } - config, err := getConfig(team) + config, err := getConfig(botname) if err != nil { fmt.Printf("%v\n", err) os.Exit(1) } - err = provisionNewKey(config, keyPath) + err = provisionNewKey(config, keyPath, requestedPrincipal) if err != nil { fmt.Printf("%v\n", err) os.Exit(1) @@ -104,6 +104,7 @@ var cliArguments = []kssh.CLIArgument{ {Name: "--help", HasArgument: false}, {Name: "-v", HasArgument: false, Preserve: true}, {Name: "--set-keybase-binary", HasArgument: true}, + {Name: "--request-realm", HasArgument: true}, } var VersionNumber = "master" @@ -131,7 +132,8 @@ GLOBAL OPTIONS: --set-default-user Set the default SSH user to be used for kssh. Useful if you use ssh configs that do not set a default SSH user --clear-default-user Clear the default SSH user - --set-keybase-binary Run kssh with a specific keybase binary rather than resolving via $PATH `, VersionNumber) + --set-keybase-binary Advanced feature: Run kssh with a specific keybase binary rather than resolving via $PATH + --request-realm Advanced feature: Request a specific M of N enabled realm in your provisioned certificate `, VersionNumber) } type Action int @@ -141,55 +143,56 @@ const ( SSH ) -// Returns botname, remaining arguments, action, error +// Returns botname, requestedPrincipal, remaining arguments, action, error // If the argument requires exiting after processing, it will call os.Exit -func handleArgs(args []string) (string, []string, Action, error) { +func handleArgs(args []string) (string, string, []string, Action, error) { remaining, found, err := kssh.ParseArgs(args, cliArguments) if err != nil { - return "", nil, 0, fmt.Errorf("Failed to parse provided arguments: %v", err) + return "", "", nil, 0, fmt.Errorf("Failed to parse provided arguments: %v", err) } - team := "" + requestedPrincipal := "" + botname := "" action := SSH for _, arg := range found { if arg.Argument.Name == "--bot" { - team = arg.Value + botname = arg.Value } - if arg.Argument.Name == "--set-default-user" { - err := kssh.SetDefaultSSHUser(arg.Value) + if arg.Argument.Name == "--set-default-bot" { + // We exit immediately after setting the default bot + err := kssh.SetDefaultBot(arg.Value) if err != nil { - fmt.Printf("Failed to set the default ssh user: %v\n", err) + fmt.Printf("Failed to set the default bot: %v\n", err) os.Exit(1) } - fmt.Println("Set default ssh user, exiting...") + fmt.Println("Set default bot, exiting...") os.Exit(0) } - if arg.Argument.Name == "--clear-default-user" { - err := kssh.SetDefaultSSHUser("") + if arg.Argument.Name == "--clear-default-bot" { + err := kssh.SetDefaultBot("") if err != nil { - fmt.Printf("Failed to clear the default ssh user: %v\n", err) + fmt.Printf("Failed to clear the default bot: %v\n", err) os.Exit(1) } - fmt.Println("Cleared default ssh user, exiting...") + fmt.Println("Cleared default bot, exiting...") os.Exit(0) } - if arg.Argument.Name == "--set-default-bot" { - // We exit immediately after setting the default bot - err := kssh.SetDefaultBot(arg.Value) + if arg.Argument.Name == "--set-default-user" { + err := kssh.SetDefaultSSHUser(arg.Value) if err != nil { - fmt.Printf("Failed to set the default bot: %v\n", err) + fmt.Printf("Failed to set the default ssh user: %v\n", err) os.Exit(1) } - fmt.Println("Set default bot, exiting...") + fmt.Println("Set default ssh user, exiting...") os.Exit(0) } - if arg.Argument.Name == "--clear-default-bot" { - err := kssh.SetDefaultBot("") + if arg.Argument.Name == "--clear-default-user" { + err := kssh.SetDefaultSSHUser("") if err != nil { - fmt.Printf("Failed to clear the default bot: %v\n", err) + fmt.Printf("Failed to clear the default ssh user: %v\n", err) os.Exit(1) } - fmt.Println("Cleared default bot, exiting...") + fmt.Println("Cleared default ssh user, exiting...") os.Exit(0) } if arg.Argument.Name == "--set-keybase-binary" { @@ -211,8 +214,11 @@ func handleArgs(args []string) (string, []string, Action, error) { if arg.Argument.Name == "-v" { log.SetLevel(log.DebugLevel) } + if arg.Argument.Name == "--request-realm" { + requestedPrincipal = arg.Value + } } - return team, remaining, action, nil + return botname, requestedPrincipal, remaining, action, nil } // Get the kssh.ConfigFile. botname is the team specified via --bot if one was specified, otherwise the empty string @@ -289,7 +295,7 @@ func isValidCert(keyPath string) bool { } // Provision a new signed SSH key with the given config -func provisionNewKey(config kssh.ConfigFile, keyPath string) error { +func provisionNewKey(config kssh.ConfigFile, keyPath string, requestedPrincipal string) error { log.Debug("Generating a new SSH key...") // Make ~/.ssh/ in case it doesn't exist @@ -316,8 +322,9 @@ func provisionNewKey(config kssh.ConfigFile, keyPath string) error { log.Debug("Requesting signature from the CA....") resp, err := kssh.GetSignedKey(config, shared.SignatureRequest{ - UUID: randomUUID.String(), - SSHPublicKey: string(pubKey), + UUID: randomUUID.String(), + SSHPublicKey: string(pubKey), + RequestedPrincipal: requestedPrincipal, }) if err != nil { return fmt.Errorf("Failed to get a signed key from the CA: %v", err) diff --git a/src/keybaseca/bot/bot.go b/src/keybaseca/bot/bot.go index 02daaaf..aa3dcb1 100644 --- a/src/keybaseca/bot/bot.go +++ b/src/keybaseca/bot/bot.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1" + "github.com/keybase/bot-sshca/src/keybaseca/botwrapper" auditlog "github.com/keybase/bot-sshca/src/keybaseca/log" @@ -36,8 +38,19 @@ func GetUsername(conf config.Config) (string, error) { return username, nil } +// A struct used for the bot to keep track of any outstanding M of N requests and the data associated with the requests +type OutstandingMOfNSignatureRequest struct { + SignatureRequest shared.SignatureRequest + RequestMessageID chat1.MessageID + Approvers []string + ConvID string +} + // Start the keybaseca bot in an infinite loop. Does not return unless it encounters an unrecoverable error. func StartBot(conf config.Config) error { + // Initialize a list for the outstanding M of N signature requests + outstandingMOfNRequests := []OutstandingMOfNSignatureRequest{} + kbc, err := GetKBChat(conf) if err != nil { return fmt.Errorf("error starting Keybase chat: %v", err) @@ -60,12 +73,45 @@ func StartBot(conf config.Config) error { return fmt.Errorf("failed to read message: %v", err) } - if msg.Message.Content.TypeName != "text" { + if msg.Message.Content.TypeName != "text" && msg.Message.Content.TypeName != "reaction" { continue } - messageBody := msg.Message.Content.Text.Body + if msg.Message.Content.TypeName == "reaction" { + emoji := msg.Message.Content.Reaction.Body + reactionTo := msg.Message.Content.Reaction.MessageID + log.Debug("Examining reaction...") + for _, outstanding := range outstandingMOfNRequests { + if outstanding.RequestMessageID == reactionTo { + log.Debug("Message is a reaction to an outstanding M of N request") + approver := msg.Message.Sender.Username + if emoji == ":+1:" && isValidApprover(conf, approver, outstanding.SignatureRequest) { + isDuplicateApprover := addApprover(&outstanding, approver) + if isDuplicateApprover { + log.Debugf("Rejecting duplicate approver %s since they already approved the M of N request with ID=%s", approver, outstanding.SignatureRequest.UUID) + continue + } + log.WithField("requester", outstanding.SignatureRequest.Username). + WithField("current_approver", approver). + WithField("all_approvers", outstanding.Approvers). + Debugf("Message approved request") + threshold := conf.GetMOfNRequiredApproversCount() + if len(outstanding.Approvers) >= threshold { + respondToSignatureRequest(conf, kbc, outstanding.SignatureRequest, outstanding.SignatureRequest.Username, outstanding.RequestMessageID, outstanding.ConvID) + auditlog.Log(conf, fmt.Sprintf("M of N SignatureRequest id=%s approved by %v", outstanding.SignatureRequest.UUID, outstanding.Approvers)) + } + } else { + log.Debug("Message did not approve request") + } + continue + } + } + log.Debug("Ignoring reaction since it is not a reaction on an outstanding M of N request") + continue + } + + messageBody := msg.Message.Content.Text.Body log.Debugf("Received message in %s#%s: %s", msg.Message.Channel.Name, msg.Message.Channel.TopicName, messageBody) if msg.Message.Sender.Username == kbc.GetUsername() { @@ -90,33 +136,38 @@ func StartBot(conf config.Config) error { // Ack any AckRequests so that kssh can determine whether it has fully connected _, err = kbc.SendMessageByConvID(msg.Message.ConvID, shared.GenerateAckResponse(messageBody)) if err != nil { - LogError(conf, kbc, msg, err) + LogError(conf, kbc, msg.Message.Sender.Username, msg.Message.Id, msg.Message.ConvID, err) continue } } else if strings.HasPrefix(messageBody, shared.SignatureRequestPreamble) { log.Debug("Responding to SignatureRequest") signatureRequest, err := shared.ParseSignatureRequest(messageBody) if err != nil { - LogError(conf, kbc, msg, err) + LogError(conf, kbc, msg.Message.Sender.Username, msg.Message.Id, msg.Message.ConvID, err) continue } signatureRequest.Username = msg.Message.Sender.Username signatureRequest.DeviceName = msg.Message.Sender.DeviceName - signatureResponse, err := sshutils.ProcessSignatureRequest(conf, signatureRequest) - if err != nil { - LogError(conf, kbc, msg, err) - continue - } - response, err := json.Marshal(signatureResponse) - if err != nil { - LogError(conf, kbc, msg, err) - continue - } - _, err = kbc.SendMessageByConvID(msg.Message.ConvID, shared.SignatureResponsePreamble+string(response)) - if err != nil { - LogError(conf, kbc, msg, err) - continue + // Process the signature request depending on whether they requested M of N enabled principals or not + if signatureRequest.RequestedPrincipal == "" { + // If they didn't request a principal just respond immediately + respondToSignatureRequest(conf, kbc, signatureRequest, msg.Message.Sender.Username, msg.Message.Id, msg.Message.ConvID) + } else { + if isMOfNPrincipal(conf, signatureRequest.RequestedPrincipal) { + // If they requested a principal that doesn't require M of N authorization, respond immediately + respondToSignatureRequest(conf, kbc, signatureRequest, msg.Message.Sender.Username, msg.Message.Id, msg.Message.ConvID) + } else { + // If the principal requires M of N authorization, treat it as such + resp, err := kbc.SendMessageByConvID(msg.Message.ConvID, buildMOfNApprovalRequestMessage(conf, msg.Message.Sender.Username, signatureRequest.RequestedPrincipal)) + if err != nil { + LogError(conf, kbc, msg.Message.Sender.Username, msg.Message.Id, msg.Message.ConvID, err) + continue + } + + outstandingMOfNRequests = append(outstandingMOfNRequests, + OutstandingMOfNSignatureRequest{SignatureRequest: signatureRequest, Approvers: []string{}, RequestMessageID: *resp.Result.MessageID, ConvID: msg.Message.ConvID}) + } } } else { log.Debug("Ignoring unparsed message") @@ -124,12 +175,86 @@ func StartBot(conf config.Config) error { } } +// Add the given approver to the list of approvers in the given outstanding m of n signature request if the +// given user has not already approved the request. Returns whether the given approver has already +// approved the request. +func addApprover(request *OutstandingMOfNSignatureRequest, approver string) bool { + for _, curApprover := range request.Approvers { + if curApprover == approver { + return true + } + } + request.Approvers = append(request.Approvers, approver) + return false +} + +// Build an announcement message to be sent in Keybase chat about the sender requesting access to the requested principal +func buildMOfNApprovalRequestMessage(conf config.Config, sender string, requestedPrincipal string) string { + approvers := []string{} + for _, approver := range conf.GetMOfNApprovers() { + approvers = append(approvers, "@"+approver) + } + + return fmt.Sprintf("@%s has requested access to the M of N realm %s! In order to approve this access, "+ + "reply with a thumbs-up to this message. (Configured approvers: %s)", sender, requestedPrincipal, strings.Join(approvers, ", ")) +} + +// Returns whether the given principal is a principal that requires M of N approval +func isMOfNPrincipal(conf config.Config, requestedPrincipal string) bool { + for _, team := range conf.GetMOfNApprovers() { + if team == requestedPrincipal { + return true + } + } + return false +} + +// Note that this function is a key security barrier for the M of N feature. This function checks that only people +// in the define list of approvers can approve a request and that people cannot approve their own request. +func isValidApprover(conf config.Config, senderUsername string, signatureRequest shared.SignatureRequest) bool { + validApprover := false + for _, knownApprover := range conf.GetMOfNApprovers() { + if knownApprover == senderUsername { + validApprover = true + } + } + if !validApprover { + log.Debug("Reply came from someone who isn't a valid M of N approver, rejecting!") + return false + } + if senderUsername == signatureRequest.Username { + log.Debug("Reply came from the sender of the signature request, rejecting!") + return false + } + return true +} + +// Respond to the given SignatureRequest and log any errors that are produced. This function does not return any error. +func respondToSignatureRequest(conf config.Config, kbc *kbchat.API, signatureRequest shared.SignatureRequest, username string, messageID chat1.MessageID, conversationID string) { + signatureResponse, err := sshutils.ProcessSignatureRequest(conf, signatureRequest) + if err != nil { + LogError(conf, kbc, username, messageID, conversationID, err) + return + } + + response, err := json.Marshal(signatureResponse) + if err != nil { + LogError(conf, kbc, username, messageID, conversationID, err) + return + } + _, err = kbc.SendMessageByConvID(conversationID, shared.SignatureResponsePreamble+string(response)) + if err != nil { + LogError(conf, kbc, username, messageID, conversationID, err) + return + } +} + // Log the given error to Keybase chat and to the configured log file. Used so that the chatbot does not crash // due to an error caused by a malformed message. -func LogError(conf config.Config, kbc *kbchat.API, msg kbchat.SubscriptionMessage, err error) { - message := fmt.Sprintf("Encountered error while processing message from %s (messageID:%d): %v", msg.Message.Sender.Username, msg.Message.Id, err) +func LogError(conf config.Config, kbc *kbchat.API, username string, messageID chat1.MessageID, conversationID string, err error) { + message := fmt.Sprintf("Encountered error while processing message from %s (messageID:%d): %v", username, messageID, err) auditlog.Log(conf, message) - _, e := kbc.SendMessageByConvID(msg.Message.ConvID, message) + _, e := kbc.SendMessageByConvID(conversationID, message) if e != nil { auditlog.Log(conf, fmt.Sprintf("failed to log an error to chat (something is probably very wrong): %v", err)) } diff --git a/src/keybaseca/config/config.go b/src/keybaseca/config/config.go index d044a6e..29bbd25 100644 --- a/src/keybaseca/config/config.go +++ b/src/keybaseca/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "io/ioutil" "os" + "strconv" "strings" "github.com/keybase/bot-sshca/src/keybaseca/constants" @@ -29,6 +30,9 @@ type Config interface { GetStrictLogging() bool GetAnnouncement() string DebugString() string + GetMOfNTeams() []string + GetMOfNApprovers() []string + GetMOfNRequiredApproversCount() int } // Validate the given config file. If offline, do so without connecting to keybase (used in code that is meant @@ -76,10 +80,39 @@ func ValidateConfig(conf EnvConfig, offline bool) error { } } } + err := validateMOfNConfig(conf) + if err != nil { + return err + } log.Debugf("Validated config: %s", conf.DebugString()) return nil } +func validateMOfNConfig(conf EnvConfig) error { + if len(conf.GetMOfNTeams()) != 0 || len(conf.GetMOfNApprovers()) != 0 { + if len(conf.GetMOfNTeams()) != 0 && len(conf.GetMOfNApprovers()) == 0 { + return fmt.Errorf("cannot specify CTRL_POLICY_MOFN_TEAMS without setting CTRL_POLICY_MOFN_APPROVERS") + } + if len(conf.GetMOfNTeams()) == 0 && len(conf.GetMOfNApprovers()) != 0 { + return fmt.Errorf("cannot specify CTRL_POLICY_MOFN_APPROVERS without setting CTRL_POLICY_MOFN_TEAMS") + } + _, err := conf.getMOfNRequiredApproversCount() + if err != nil { + return fmt.Errorf("failed to parse CTRL_POLICY_MOFN_REQUIRED_COUNT value as a intenger: %v", err) + } + for _, mofnTeam := range conf.GetMOfNTeams() { + found := false + for _, team := range conf.GetTeams() { + found = found || team == mofnTeam + } + if !found { + return fmt.Errorf("M of N configured team %s is not in the list of configured teams %v (all M of N teams must be listed in the TEAMS environment variable)", mofnTeam, conf.GetTeams()) + } + } + } + return nil +} + func validateUsernamePaperkey(homedir, username, paperkey string) error { api, err := botwrapper.GetKBChat(homedir, username, paperkey) if err != nil { @@ -196,15 +229,7 @@ func (ef *EnvConfig) GetKeyExpiration() string { // Get the list of keybase teams configured to be used with the bot. func (ef *EnvConfig) GetTeams() []string { - split := strings.Split(os.Getenv("TEAMS"), ",") - var teams []string - for _, item := range split { - trimmed := strings.TrimSpace(item) - if trimmed != "" { - teams = append(teams, trimmed) - } - } - return teams + return commaSeparatedStringToList(os.Getenv("TEAMS")) } // Get the location for the bot's audit logs. May be empty. @@ -272,3 +297,44 @@ func splitTeamChannel(teamChannel string) (string, string, error) { } return split[0], split[1], nil } + +func (ef *EnvConfig) GetMOfNTeams() []string { + mofnTeams := os.Getenv("CTRL_POLICY_MOFN_TEAMS") + if mofnTeams == "" { + return []string{} + } + return commaSeparatedStringToList(mofnTeams) +} + +func (ef *EnvConfig) GetMOfNApprovers() []string { + mofnApprovers := os.Getenv("CTRL_POLICY_MOFN_APPROVERS") + if mofnApprovers == "" { + return []string{} + } + return commaSeparatedStringToList(mofnApprovers) +} + +func (ef *EnvConfig) getMOfNRequiredApproversCount() (int, error) { + val := os.Getenv("CTRL_POLICY_MOFN_REQUIRED_COUNT") + if val == "" { + return 1, nil + } + return strconv.Atoi(val) +} + +func (ef *EnvConfig) GetMOfNRequiredApproversCount() int { + num, _ := ef.getMOfNRequiredApproversCount() + return num +} + +func commaSeparatedStringToList(val string) []string { + split := strings.Split(val, ",") + var teams []string + for _, item := range split { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + teams = append(teams, trimmed) + } + } + return teams +} diff --git a/src/keybaseca/log/log.go b/src/keybaseca/log/log.go index a35c864..82c46e3 100644 --- a/src/keybaseca/log/log.go +++ b/src/keybaseca/log/log.go @@ -14,10 +14,10 @@ import ( // Log attempts to log the given string to a file. If conf.GetStrictLogging() it will panic if it fails // to log to the file. If conf.GetStrictLogging() is false, it may silently fail func Log(conf config.Config, str string) { - strWithTs := fmt.Sprintf("[%s] %s", time.Now().String(), str) + strWithTs := strings.TrimSpace(fmt.Sprintf("[%s] %s", time.Now().String(), str)) + "\n" if conf.GetLogLocation() == "" { - fmt.Print(strWithTs + "\n") + fmt.Print(strWithTs) } else { err := appendToFile(conf.GetLogLocation(), strWithTs) if err != nil { diff --git a/src/keybaseca/sshutils/sshutils.go b/src/keybaseca/sshutils/sshutils.go index b0ae033..7911b00 100644 --- a/src/keybaseca/sshutils/sshutils.go +++ b/src/keybaseca/sshutils/sshutils.go @@ -80,6 +80,7 @@ func ProcessSignatureRequest(conf config.Config, sr shared.SignatureRequest) (re if err != nil { return } + principals, err := getPrincipals(conf, sr) if err != nil { return @@ -174,14 +175,36 @@ func getPrincipals(conf config.Config, sr shared.SignatureRequest) (string, erro } } + // Map from a team to whether or not it is an M of N enabled team + // Note that this is a key security barrier in the M of N feature. This ensures that signature requests that do + // not specify a principal are not given any M of N enabled principals. + teamToMOfNRequired := make(map[string]bool) + for _, team := range conf.GetTeams() { + teamToMOfNRequired[team] = false + } + for _, team := range conf.GetMOfNTeams() { + teamToMOfNRequired[team] = true + } + // Iterate through the teams in the config file and use the subteam as the principal - // if the user is in that subteam + // if the user is in that subteam and the subteam doesn't require M of N approval var principals []string for _, team := range conf.GetTeams() { - result, ok := teamToMembership[team] - if ok && result { + isMember, ok1 := teamToMembership[team] + requiresMOfNApproval, ok2 := teamToMOfNRequired[team] + if ok1 && isMember && ok2 && !requiresMOfNApproval { principals = append(principals, team) } } + + // Add the specific principals that they requested. Note that getPrincipals() is only called if the signature + // request has been validated and the M of N request been approved. + requestedTeam := sr.RequestedPrincipal + isMember, ok := teamToMembership[requestedTeam] + _, isConfiguredTeam := teamToMOfNRequired[requestedTeam] + if ok && isMember && isConfiguredTeam { + principals = append(principals, requestedTeam) + } + return strings.Join(principals, ","), nil } diff --git a/src/kssh/bot.go b/src/kssh/bot.go index 66d1f3f..d5d62db 100644 --- a/src/kssh/bot.go +++ b/src/kssh/bot.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "github.com/sirupsen/logrus" + "github.com/keybase/bot-sshca/src/shared" "github.com/keybase/go-keybase-chat-bot/kbchat" ) @@ -14,6 +16,13 @@ import ( func GetSignedKey(config ConfigFile, request shared.SignatureRequest) (shared.SignatureResponse, error) { empty := shared.SignatureResponse{} + timeout := 5 * time.Second + if request.RequestedPrincipal != "" { + timeout = time.Hour + logrus.Debug("Requesting an M of N enabled certificate. Setting time out to one hour...") + fmt.Println("Requesting an M of N enabled SSH certificate. See Keybase Chat to find an approver. ") + } + // Start communicating with the Keybase chat API runOptions := kbchat.RunOptions{KeybaseLocation: GetKeybaseBinaryPath()} kbc, err := kbchat.Start(runOptions) @@ -31,19 +40,23 @@ func GetSignedKey(config ConfigFile, request shared.SignatureRequest) (shared.Si return empty, fmt.Errorf("error subscribing to messages: %v", err) } + terminateAllCh := make(chan struct{}) + defer close(terminateAllCh) + // If we just send our signature request to chat, we hit a race condition where if the CA responds fast enough // we will miss the response from the CA. We fix this with a simple ACKing algorithm: // 1. Send an AckRequest every 100ms until an Ack is received. // 2. Once an Ack is received, we know we are correctly receiving messages // 3. Send the signature request payload and get back a signed cert // We implement this with a terminatable goroutine that just sends acks and a while(true) loop that looks for responses - terminateRoutineCh := make(chan interface{}) + terminateAckRequestCh := make(chan interface{}) go func() { // Make the AckRequests send less often over time by tracking how many we've sent numberSent := 0 for { select { - case <-terminateRoutineCh: + case <-terminateAckRequestCh: + case <-terminateAllCh: return default: @@ -57,10 +70,26 @@ func GetSignedKey(config ConfigFile, request shared.SignatureRequest) (shared.Si } }() + // A goroutine that simply prints a dot every 500ms until terminated. Used to show progress to users while they + // wait for a response from the bot. + go func() { + for { + select { + case <-terminateAllCh: + return + default: + + } + + fmt.Print(".") + time.Sleep(500 * time.Millisecond) + } + }() + hasBeenAcked := false startTime := time.Now() for { - if time.Since(startTime) > 5*time.Second { + if time.Since(startTime) > timeout { return empty, fmt.Errorf("timed out while waiting for a response from the CA") } msg, err := sub.Read() @@ -81,7 +110,7 @@ func GetSignedKey(config ConfigFile, request shared.SignatureRequest) (shared.Si if shared.IsAckResponse(messageBody) && !hasBeenAcked { // We got an Ack so we terminate our AckRequests and send the real payload hasBeenAcked = true - terminateRoutineCh <- true + terminateAckRequestCh <- true marshaledRequest, err := json.Marshal(request) if err != nil { return empty, err diff --git a/src/shared/chat_types.go b/src/shared/chat_types.go index 466a135..68702ad 100644 --- a/src/shared/chat_types.go +++ b/src/shared/chat_types.go @@ -17,10 +17,11 @@ import ( // The body of signature request messages sent over KB chat type SignatureRequest struct { - SSHPublicKey string `json:"ssh_public_key"` - UUID string `json:"uuid"` - Username string `json:"-"` - DeviceName string `json:"-"` + SSHPublicKey string `json:"ssh_public_key"` + UUID string `json:"uuid"` + RequestedPrincipal string `json:"requested_principal,omitempty"` + Username string `json:"-"` + DeviceName string `json:"-"` } // The preamble used at the start of signature request messages diff --git a/tests/Dockerfile-autoapprover b/tests/Dockerfile-autoapprover new file mode 100644 index 0000000..10eb305 --- /dev/null +++ b/tests/Dockerfile-autoapprover @@ -0,0 +1,10 @@ +# See docker/Dockerfile-keybase +FROM keybase:latest + +# Install python +USER root +RUN apt-get update && apt-get install python3.7 python3-pip gettext -y +RUN python3.7 -m pip install pykeybasebot flask +USER keybase + +COPY tests/autoapprover.py . \ No newline at end of file diff --git a/tests/Dockerfile-cabot b/tests/Dockerfile-cabot index d7f328d..7e7c181 100644 --- a/tests/Dockerfile-cabot +++ b/tests/Dockerfile-cabot @@ -2,5 +2,5 @@ FROM ca:latest USER root -RUN apt-get install python3 python3-pip gettext -y +RUN apt-get update && apt-get install python3 python3-pip gettext -y RUN pip3 install flask diff --git a/tests/Dockerfile-kssh b/tests/Dockerfile-kssh index 41a6493..b424f6e 100644 --- a/tests/Dockerfile-kssh +++ b/tests/Dockerfile-kssh @@ -1,37 +1,10 @@ -# This dockerfile builds a container capable of running kssh. Note that a lot of this code is duplicated -# between this file and Dockerfile-ca. -FROM ubuntu:18.04 - -# Dependencies -RUN apt-get -qq update -RUN apt-get -qq install curl software-properties-common ca-certificates gnupg -y -RUN useradd -ms /bin/bash keybase -USER keybase -WORKDIR /home/keybase - -# Download and verify the deb -RUN curl --remote-name https://prerelease.keybase.io/keybase_amd64.deb -RUN curl --remote-name https://prerelease.keybase.io/keybase_amd64.deb.sig -# Import our gpg key from our website. Pulling from key servers caused a flakey build so -# we get the key from the Keybase website instead. -RUN curl -sSL https://keybase.io/docs/server_security/code_signing_key.asc | gpg --import -# This line will error if the fingerprint of the key in the file does not match the -# known fingerprint of the our PGP key -RUN gpg --fingerprint 222B85B0F90BE2D24CFEB93F47484E50656D16C7 -# And then verify the signature now that we have the key -RUN gpg --verify keybase_amd64.deb.sig keybase_amd64.deb - -# Silence the error from dpkg about failing to configure keybase since `apt-get install -f` fixes it -USER root -RUN dpkg -i keybase_amd64.deb || true -RUN apt-get install -fy -USER keybase +# See docker/Dockerfile-keybase +FROM keybase:latest # Install go USER root RUN add-apt-repository ppa:gophers/archive -y -RUN apt-get update -y -RUN apt-get install golang-1.11-go git sudo python3 python3-pip sudo -y +RUN apt-get update -y && apt-get install golang-1.11-go git sudo python3 python3-pip sudo -y RUN pip3 install pytest requests # Make it so the keybase user has passwordless sudo so it can move the keybase binary around diff --git a/tests/bot-entrypoint.py b/tests/bot-entrypoint.py index 5344d2d..c2e5e6b 100644 --- a/tests/bot-entrypoint.py +++ b/tests/bot-entrypoint.py @@ -28,7 +28,7 @@ def load_env(): "echo yes | bin/keybaseca backup > /shared/cakey.backup\n" # The output from this sign operation is tested in test_env_1.py "ssh-keygen -t ed25519 -f /shared/userkey -N '' && bin/keybaseca sign --public-key /shared/userkey.pub > /shared/keybaseca-sign.out\n" - "bin/keybaseca service &" + "bin/keybaseca --debug service &" ) % (shlex.quote(path))) # Sleep so keybaseca has time to start time.sleep(5) diff --git a/tests/configure_accounts.py b/tests/configure_accounts.py index 266aa65..edeb064 100644 --- a/tests/configure_accounts.py +++ b/tests/configure_accounts.py @@ -58,7 +58,7 @@ def invite_to_team(self, team, username): """ assert b"Success!" in self._run_command("team", "add-member", team, "--user=%s" % username, "--role=writer") - def create_team_and_invite(self, team, username): + def create_team_and_invite(self, team, username, *usernames): """ Create a team and invite the given user to it as an admin. Team can be a subteam but the parent teams must exist :param team: A team name. Eg "foo", "foo.bar" @@ -67,6 +67,8 @@ def create_team_and_invite(self, team, username): """ self.create_team(team) self.invite_to_team(team, username) + for u in usernames: + self.invite_to_team(team, u) def create_channel(self, team, channel): """ @@ -86,7 +88,7 @@ def join_channel(self, team, channel): """ assert b"" == self._run_command("chat", "join-channel", team, channel, "--topic-type", "chat") -def write_env_files(kssh_user, ca_user, parent_team): +def write_env_files(kssh_user, ca_user, mofn_approver, parent_team): with open("tests/env.sh", "w+") as f: f.write("#!/bin/bash\n" "# File automatically generated by configure_accounts.py\n" @@ -94,6 +96,8 @@ def write_env_files(kssh_user, ca_user, parent_team): f"export BOT_PAPERKEY='{ca_user.paperkey}'\n" f"export KSSH_USERNAME='{kssh_user.username}'\n" f"export KSSH_PAPERKEY='{kssh_user.paperkey}'\n" + f"export MOFN_APPROVER_USERNAME='{mofn_approver.username}'\n" + f"export MOFN_APPROVER_PAPERKEY='{mofn_approver.paperkey}'\n" f"export SUBTEAM='{parent_team}'\n" f"export SUBTEAM_SECONDARY='{parent_team}.secondary'\n") @@ -127,17 +131,19 @@ def make_user(purpose): print("TEST SETUP - We need to do some one-time test setup.") ca_user = make_user("the CA bot") kssh_user = make_user("the kssh tester") + mofn_approver = make_user("the M of N request approver") parent_team = secure_random_str(16) - ca_user.create_team_and_invite(parent_team, kssh_user.username) + ca_user.create_team_and_invite(parent_team, kssh_user.username, mofn_approver.username) - ca_user.create_team_and_invite(parent_team + ".ssh", kssh_user.username) + ca_user.create_team_and_invite(parent_team + ".ssh", kssh_user.username, mofn_approver.username) ca_user.create_channel(parent_team + ".ssh", "ssh-provision") kssh_user.join_channel(parent_team + ".ssh", "ssh-provision") - ca_user.create_team_and_invite(parent_team + ".ssh.staging", kssh_user.username) - ca_user.create_team(parent_team + ".ssh.prod") - ca_user.create_team_and_invite(parent_team + ".ssh.root_everywhere", kssh_user.username) + mofn_approver.join_channel(parent_team + ".ssh", "ssh-provision") + ca_user.create_team_and_invite(parent_team + ".ssh.staging", kssh_user.username, mofn_approver.username) + ca_user.create_team_and_invite(parent_team + ".ssh.prod", mofn_approver.username) + ca_user.create_team_and_invite(parent_team + ".ssh.root_everywhere", kssh_user.username, mofn_approver.username) - ca_user.create_team_and_invite(parent_team + ".secondary", kssh_user.username) + ca_user.create_team_and_invite(parent_team + ".secondary", kssh_user.username, mofn_approver.username) - write_env_files(kssh_user, ca_user, parent_team) + write_env_files(kssh_user, ca_user, mofn_approver, parent_team) diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index a3c87cd..0b86b21 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -1,5 +1,6 @@ version: '3' services: + # The container running the CA bot ca-bot: image: ca-bot container_name: ca-bot @@ -10,6 +11,7 @@ services: - BOT_PAPERKEY - BOT_USERNAME - SUBTEAM + - MOFN_APPROVER_USERNAME volumes: - app-volume:/shared/ user: root @@ -19,6 +21,7 @@ services: depends_on: - sshd-prod - sshd-staging + # The test container where kssh is tested via pytest kssh: image: kssh container_name: kssh @@ -39,6 +42,7 @@ services: - sshd-staging - sshd-prod - ca-bot + - autoapprover # An ssh server that will accept signed requests with the principal "staging" sshd-staging: image: sshd-staging @@ -63,5 +67,19 @@ services: root_principal: ${SUBTEAM}.ssh.root_everywhere volumes: - app-volume:/shared/ + # A container containing a simple python program that can be configured to autonmatically react to all messages + # with a thumbs-up message. Used to test the M of N approval feature. + autoapprover: + image: autoapprover + container_name: autoapprover + build: + context: ./ + dockerfile: "Dockerfile-autoapprover" + environment: + - MOFN_APPROVER_USERNAME + - MOFN_APPROVER_PAPERKEY + ports: + - 8080 + command: "python3.7 autoapprover.py" volumes: app-volume: diff --git a/tests/envFiles/test_env_5_mofn b/tests/envFiles/test_env_5_mofn new file mode 100644 index 0000000..7448cab --- /dev/null +++ b/tests/envFiles/test_env_5_mofn @@ -0,0 +1,9 @@ +# Used to test the M of N functionality whereby an approval is required for certain realms of servers +export KEY_EXPIRATION="+1h" +export LOG_LOCATION="/shared/ca.log" +export TEAMS="$SUBTEAM.ssh.staging,$SUBTEAM.ssh.prod,$SUBTEAM.ssh.root_everywhere" +export KEYBASE_PAPERKEY="$BOT_PAPERKEY" +export KEYBASE_USERNAME="$BOT_USERNAME" +export CA_KEY_LOCATION="/shared/keybase-ca-key" +export CTRL_POLICY_MOFN_TEAMS="$SUBTEAM.ssh.root_everywhere" +export CTRL_POLICY_MOFN_APPROVERS="$MOFN_APPROVER_USERNAME" diff --git a/tests/tester-entrypoint.sh b/tests/tester-entrypoint.sh index d277fe5..6ed0ac2 100755 --- a/tests/tester-entrypoint.sh +++ b/tests/tester-entrypoint.sh @@ -10,7 +10,7 @@ do sleep 1 done echo "" -sleep 2 +sleep 5 keybase oneshot --username $KSSH_USERNAME --paperkey "$KSSH_PAPERKEY" diff --git a/tests/tests/autoapprover.py b/tests/tests/autoapprover.py new file mode 100644 index 0000000..148ef0c --- /dev/null +++ b/tests/tests/autoapprover.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3.7 + +import asyncio +import logging +import os +import time +from multiprocessing import Process, Value + +from flask import Flask, request + + +import pykeybasebot.types.chat1 as chat1 +from pykeybasebot import Bot + +logging.basicConfig(level=logging.DEBUG) + + +class Handler: + """ + A Keybase chatbot handler that reacts to M of N requests with a thumbs up + """ + def __init__(self, shared_running_val: Value): + self.shared_running_val = shared_running_val + + async def __call__(self, bot, event): + if self.shared_running_val.value: + if event.msg.content.type_name != chat1.MessageTypeStrings.TEXT.value: + return + channel = event.msg.channel + msg_id = event.msg.id + body = event.msg.content.text.body + if "has requested access to the M of N realm" in body: + await bot.chat.react(channel, msg_id, ":+1:") + +# A shared boolean flag that tracks whether the auto-reacter is currently running +shared_running_val = Value('i', 0) + +def start_bot_event_loop(): + # Start the bot running in a separate process so that it doesn't block the main process that hosts the flask + # webserver + username = os.environ["MOFN_APPROVER_USERNAME"] + paperkey = os.environ["MOFN_APPROVER_PAPERKEY"] + bot = Bot( + username=username, paperkey=paperkey, + handler=Handler(shared_running_val) + ) + p = Process(target=lambda: asyncio.run(bot.start({}))) + p.start() + +app = Flask(__name__) + +@app.route('/start') +def start_autoresponder(): + global shared_running_val + shared_running_val.value = 1 + time.sleep(1) + return "OK" + +@app.route('/stop') +def stop_autoresponder(): + global shared_running_val + shared_running_val.value = 0 + time.sleep(1) + return "OK" + + +if __name__ == '__main__': + start_bot_event_loop() + app.run(host='0.0.0.0', port='8080') diff --git a/tests/tests/lib.py b/tests/tests/lib.py index bd1f565..45b819c 100644 --- a/tests/tests/lib.py +++ b/tests/tests/lib.py @@ -1,10 +1,11 @@ from contextlib import contextmanager import hashlib import os +import re import signal import subprocess import time -from typing import List, Set +from typing import List, Set, Mapping import requests @@ -116,9 +117,17 @@ def simulate_two_teams(tc: TestConfig): run_command(f"keybase fs rm /keybase/team/{tc.subteam_secondary}/kssh-client.config") @contextmanager -def outputs_audit_log(tc: TestConfig, filename: str, expected_number: int): +def outputs_audit_log(tc: TestConfig, filename: str, expected_number: int, additional_regexes: Mapping[str, int] = None, expected_principals: str = None): # A context manager that asserts that the given function triggers expected_number of audit logs to be added to the # log at the given filename + if additional_regexes is None: + additional_regexes = {} + if expected_principals is None: + expected_principals = f"principals:{tc.subteam}.ssh.staging,{tc.subteam}.ssh.root_everywhere" + elif expected_principals == ".*": + expected_principals = "" + else: + expected_principals = f"principals:{expected_principals}" # Make a set of the lines in the audit log before we ran before_lines = set(read_file(filename)) @@ -137,12 +146,22 @@ def outputs_audit_log(tc: TestConfig, filename: str, expected_number: int): cnt = 0 for line in new_lines: line = line.decode('utf-8') - if line and f"Processing SignatureRequest from user={tc.username}" in line and f"principals:{tc.subteam}.ssh.staging,{tc.subteam}.ssh.root_everywhere, expiration:+1h, pubkey:" in line: + if line and f"Processing SignatureRequest from user={tc.username}" in line and f"{expected_principals}, expiration:+1h, pubkey:" in line: cnt += 1 if cnt != expected_number: assert False, f"Found {cnt} audit log entries, expected {expected_number}! New audit logs: {new_lines}" + additional_regexes_cnt = {k: 0 for k, v in additional_regexes.items()} + for line in new_lines: + line = line.decode('utf-8') + for regex in additional_regexes: + if re.search(regex, line): + additional_regexes_cnt[regex] += 1 + for regex, actual_cnt in additional_regexes_cnt.items(): + if additional_regexes[regex] != actual_cnt: + assert False, f"Found {cnt} audit log entries matching the regex {repr(regex)}. Expected={additional_regexes[regex]}. New audit logs: {new_lines}" + def get_principals(certificateFilename: str) -> Set[str]: inPrincipalsSection = False principalsIndentationLevel = 16 diff --git a/tests/tests/test_env_1.py b/tests/tests/test_env_1.py index 7ce1a35..313b45e 100644 --- a/tests/tests/test_env_1.py +++ b/tests/tests/test_env_1.py @@ -166,4 +166,5 @@ def test_kssh_alternate_binary(self, test_config): assert_contains_hash(test_config.expected_hash, run_command_with_agent("bin/kssh -q -o StrictHostKeyChecking=no user@sshd-staging 'sha1sum /etc/unique'")) run_command("bin/kssh --set-keybase-binary ''") finally: - run_command("sudo rm /usr/local/bin/keybase") \ No newline at end of file + run_command("sudo rm /usr/local/bin/keybase") + diff --git a/tests/tests/test_env_5_mofn.py b/tests/tests/test_env_5_mofn.py new file mode 100644 index 0000000..5a9b2b1 --- /dev/null +++ b/tests/tests/test_env_5_mofn.py @@ -0,0 +1,48 @@ +import json +import subprocess + +import pytest +import requests + +from lib import TestConfig, load_env, assert_contains_hash, run_command_with_agent, outputs_audit_log, clear_keys + +from contextlib import contextmanager + +@contextmanager +def autoapprover(): + assert requests.get('http://autoapprover:8080/start').content == b"OK" + yield + assert requests.get('http://autoapprover:8080/stop').content == b"OK" + +class TestEnv5MOfN: + @pytest.fixture(autouse=True, scope='class') + def configure_env(self): + assert load_env(__file__) + + @pytest.fixture(autouse=True, scope='class') + def test_config(self): + return TestConfig.getDefaultTestConfig() + + def test_kssh_with_requested_realm(self, test_config): + with autoapprover(), \ + outputs_audit_log(test_config, + filename="/shared/ca.log", + expected_number=2, + additional_regexes={f"M of N SignatureRequest id=.* approved by ": 2}, + expected_principals=f"{test_config.subteam}.ssh.staging,{test_config.subteam}.ssh.root_everywhere"): + assert_contains_hash(test_config.expected_hash, run_command_with_agent(f"bin/kssh --request-realm {test_config.subteam}.ssh.root_everywhere -q -o StrictHostKeyChecking=no root@sshd-prod 'sha1sum /etc/unique'")) + clear_keys() + assert_contains_hash(test_config.expected_hash, run_command_with_agent(f"bin/kssh --request-realm {test_config.subteam}.ssh.root_everywhere -q -o StrictHostKeyChecking=no root@sshd-staging 'sha1sum /etc/unique'")) + + def test_kssh_with_requested_realm_no_approval(self, test_config): + with outputs_audit_log(test_config, filename="/shared/ca.log", expected_number=0, expected_principals=".*"): + with pytest.raises(subprocess.TimeoutExpired): + run_command_with_agent(f"bin/kssh --request-realm {test_config.subteam}.ssh.root_everywhere -q -o StrictHostKeyChecking=no root@sshd-prod 'sha1sum /etc/unique'") + + def test_kssh_without_requested_realm(self, test_config): + with outputs_audit_log(test_config, filename="/shared/ca.log", expected_number=2, expected_principals=f"{test_config.subteam}.ssh.staging"): + with pytest.raises(subprocess.CalledProcessError): + run_command_with_agent(f"bin/kssh -q -o StrictHostKeyChecking=no root@sshd-prod 'sha1sum /etc/unique'") + clear_keys() + with pytest.raises(subprocess.CalledProcessError): + run_command_with_agent(f"bin/kssh -q -o StrictHostKeyChecking=no root@sshd-prod 'sha1sum /etc/unique'")