Skip to content

Commit

Permalink
Implement Passbolt TOTP MFA (#19)
Browse files Browse the repository at this point in the history
* Implement Passbolt TOTP MFA
* Add MFA E2E test
  • Loading branch information
bastjan authored Apr 5, 2024
1 parent 50e3400 commit 46b215b
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 16 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions e2e/interactive.tcl
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions e2e/lib/common.tcl
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions e2e/non-interactive.tcl
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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"
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"
Expand Down
86 changes: 71 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"io/fs"
"net/http"
"os"
"os/signal"
"strings"
Expand Down Expand Up @@ -45,6 +46,7 @@ var sampleConfig = `

const (
envVarPassphrase = "EMR_PASSPHRASE"
envVarTOTPToken = "EMR_TOTP_TOKEN"
envVarKubernetesEndpoint = "EMR_KUBERNETES_ENDPOINT"

defaultEndpoint = "https://cloud.passbolt.com/vshn"
Expand All @@ -54,6 +56,8 @@ const (

clusterOverviewPage = "https://wiki.vshn.net/x/4whJF"

passboltMFACookieName = "passbolt_mfa"

userAgent = "emergency-credentials-receive/0.0.0"
)

Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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
}

0 comments on commit 46b215b

Please sign in to comment.