From 46b215bc350eb2021d4de38cbe9f1b4ea9cac23c Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Fri, 5 Apr 2024 14:54:25 +0200 Subject: [PATCH] Implement Passbolt TOTP MFA (#19) * Implement Passbolt TOTP MFA * Add MFA E2E test --- .github/workflows/test.yml | 3 +- e2e/interactive.tcl | 6 +++ e2e/lib/common.tcl | 5 +++ e2e/non-interactive.tcl | 2 + main.go | 86 +++++++++++++++++++++++++++++++------- 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e556e19..33787d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,10 +24,11 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Install expect - run: sudo apt-get -y install expect + run: sudo apt-get -y install expect oathtool - name: Run tests env: E2E_PASSBOLT_PASSPHRASE: ${{ secrets.E2E_PASSBOLT_PASSPHRASE }} E2E_PASSBOLT_PRIVATE_KEY: ${{ secrets.E2E_PASSBOLT_PRIVATE_KEY }} + E2E_PASSBOLT_TOTP_KEY_BASE32: ${{ secrets.E2E_PASSBOLT_TOTP_KEY_BASE32 }} run: make test test-e2e diff --git a/e2e/interactive.tcl b/e2e/interactive.tcl index 1068084..bd33f7c 100755 --- a/e2e/interactive.tcl +++ b/e2e/interactive.tcl @@ -9,6 +9,7 @@ set api_endpoint "https://api.lab-cloudscale-rma-0.appuio.cloud:6443" set passphrase [getenv_or_die "E2E_PASSBOLT_PASSPHRASE"] set private_key [getenv_or_die "E2E_PASSBOLT_PRIVATE_KEY"] +set totp_key [getenv_or_die "E2E_PASSBOLT_TOTP_KEY_BASE32"] proc expect_prompt {prompt} { expect -exact "$prompt" @@ -50,6 +51,11 @@ expect_prompt "Enter your cluster ID" send -- "$cluster_id" send -- "\r" +log "Expecting TOTP prompt" +expect_prompt "Passbolt TOTP token" +send -- [totp_code_from_key $totp_key] +send -- "\r" + log "Expecting to have valid credentials" expect -exact "2 buckets with credentials found" expect -exact "Emergency credentials found" diff --git a/e2e/lib/common.tcl b/e2e/lib/common.tcl index 18afe52..d640e96 100644 --- a/e2e/lib/common.tcl +++ b/e2e/lib/common.tcl @@ -24,3 +24,8 @@ proc getenv_or_die {var} { } return "$::env($var)" } + +proc totp_code_from_key {key} { + set otp [exec oathtool --totp --base32 $key] + return $otp +} diff --git a/e2e/non-interactive.tcl b/e2e/non-interactive.tcl index 37d0dce..0ba3ea2 100755 --- a/e2e/non-interactive.tcl +++ b/e2e/non-interactive.tcl @@ -9,6 +9,7 @@ set api_endpoint "https://api.lab-cloudscale-rma-0.appuio.cloud:6443" set passphrase [getenv_or_die "E2E_PASSBOLT_PASSPHRASE"] set private_key [getenv_or_die "E2E_PASSBOLT_PRIVATE_KEY"] +set totp_key [getenv_or_die "E2E_PASSBOLT_TOTP_KEY_BASE32"] file delete -force config.yaml file delete -force "em-$cluster_id" @@ -16,6 +17,7 @@ set ::env(EMR_CONFIG_DIR) [pwd] log "Starting tool" set ::env(EMR_PASSPHRASE) "$passphrase" +set ::env(EMR_TOTP_TOKEN) [totp_code_from_key $totp_key] set ::env(EMR_KUBERNETES_ENDPOINT) "$api_endpoint" exec -- jq --null-input --arg key "$private_key" {{passbolt_key: $key}} > config.yaml spawn ../emergency-credentials-receive -omit-token-output "$cluster_id" diff --git a/main.go b/main.go index 0ed917f..5729e8d 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "io/fs" + "net/http" "os" "os/signal" "strings" @@ -45,6 +46,7 @@ var sampleConfig = ` const ( envVarPassphrase = "EMR_PASSPHRASE" + envVarTOTPToken = "EMR_TOTP_TOKEN" envVarKubernetesEndpoint = "EMR_KUBERNETES_ENDPOINT" defaultEndpoint = "https://cloud.passbolt.com/vshn" @@ -54,6 +56,8 @@ const ( clusterOverviewPage = "https://wiki.vshn.net/x/4whJF" + passboltMFACookieName = "passbolt_mfa" + userAgent = "emergency-credentials-receive/0.0.0" ) @@ -106,21 +110,7 @@ func main() { os.Exit(1) } - passphrase := os.Getenv("EMR_PASSPHRASE") - if passphrase == "" && isTerminal { - pf, err := inputs.PassphraseInput("Enter your Passbolt passphrase", "") - if err != nil { - lln("Error retrieving passbolt passphrase: ", err) - os.Exit(1) - } - passphrase = pf - } else if passphrase == "" { - lln("Passphrase cannot be empty.") - lln("Provide interactively or set EMR_PASSPHRASE environment variable.") - os.Exit(1) - } else { - lln("Using passphrase from EMR_PASSPHRASE environment variable.") - } + passphrase := envOrPrompt(envVarPassphrase, "Passbolt passphrase", true) if clusterId == "" { cid, err := inputs.LineInput("Enter the ID of the cluster you want to access", "c-crashy-wreck-1234") @@ -141,6 +131,7 @@ func main() { lf("Error creating passbolt client: %v\n", err) os.Exit(1) } + client.MFACallback = mfaCallback lln("Logging into passbolt...") ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) @@ -394,3 +385,68 @@ type encryptedToken struct { type encryptedTokenSecret struct { Data string `json:"data"` } + +// mfaCallback is a callback function for the passbolt client to handle MFA challenges. +// It will prompt the user for a TOTP token if needed. +// It will return the MFA cookie if successful. +// Currently only TOTP is supported, Passbolt does not support other MFA methods as of 05.04.2024. +func mfaCallback(ctx context.Context, c *api.Client, res *api.APIResponse) (http.Cookie, error) { + var challenge api.MFAChallenge + if err := json.Unmarshal(res.Body, &challenge); err != nil { + return http.Cookie{}, fmt.Errorf("error parsing MFA Challenge: %w", err) + } + if challenge.Provider.TOTP == "" { + return http.Cookie{}, fmt.Errorf("server provided no TOTP provider, only TOTP is supported currently") + } + + code := envOrPrompt(envVarTOTPToken, "Passbolt TOTP token", false) + if code == "" { + lln("Passbolt TOTP token is required.") + os.Exit(1) + } + + raw, apiRes, err := c.DoCustomRequestAndReturnRawResponse(ctx, "POST", "mfa/verify/totp.json", "v2", api.MFAChallengeResponse{ + TOTP: code, + }, nil) + if err != nil { + return http.Cookie{}, fmt.Errorf("error verifying MFA challenge: %w (api response: %+v, code: %d)", err, apiRes, raw.StatusCode) + } + // MFA worked so lets find the cookie and return it + cookieNames := make([]string, 0, len(raw.Cookies())) + for _, cookie := range raw.Cookies() { + cookieNames = append(cookieNames, cookie.Name) + if cookie.Name == passboltMFACookieName { + return *cookie, nil + } + } + return http.Cookie{}, fmt.Errorf("unable to find MFA cookie %q, cookies found: %v", passboltMFACookieName, cookieNames) +} + +// envOrPrompt returns the value of an environment variable or prompts the user for input. +// If the environment variable is empty, it will prompt the user for input. +// If the environment variable is empty and the terminal is not interactive, it will exit the program with an error. +// If the environment variable is not empty, it will return the value. +func envOrPrompt(envVar, inputDesc string, mask bool) string { + in := os.Getenv(envVar) + if in == "" && isTerminal { + var pf string + var err error + if mask { + pf, err = inputs.PassphraseInput(fmt.Sprintf("Enter your %s", inputDesc), "") + } else { + pf, err = inputs.LineInput(fmt.Sprintf("Enter your %s", inputDesc), "") + } + if err != nil { + lf("Error retrieving %s: %s\n", inputDesc, err) + os.Exit(1) + } + in = pf + } else if in == "" { + lf("%s cannot be empty.\n", inputDesc) + lf("Provide interactively or set %q environment variable.\n", envVar) + os.Exit(1) + } else { + lf("Using TOTP token from %q environment variable.\n", envVar) + } + return in +}