Skip to content

Commit

Permalink
Merge pull request #744 from sledigabel/add_pinentry_prompter
Browse files Browse the repository at this point in the history
Adding Pinentry as a prompter
  • Loading branch information
Mark Wolfe authored Dec 16, 2021
2 parents 8c1b6fe + 31ec234 commit f872b40
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 8 deletions.
1 change: 1 addition & 0 deletions cmd/saml2aws/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func main() {
app.Flag("session-duration", "The duration of your AWS Session. (env: SAML2AWS_SESSION_DURATION)").Envar("SAML2AWS_SESSION_DURATION").IntVar(&commonFlags.SessionDuration)
app.Flag("disable-keychain", "Do not use keychain at all. This will also disable Okta sessions & remembering MFA device. (env: SAML2AWS_DISABLE_KEYCHAIN)").Envar("SAML2AWS_DISABLE_KEYCHAIN").BoolVar(&commonFlags.DisableKeychain)
app.Flag("region", "AWS region to use for API requests, e.g. us-east-1, us-gov-west-1, cn-north-1 (env: SAML2AWS_REGION)").Envar("SAML2AWS_REGION").Short('r').StringVar(&commonFlags.Region)
app.Flag("prompter", "The prompter to use for user input (default, pinentry)").StringVar(&commonFlags.Prompter)

// `configure` command and settings
cmdConfigure := app.Command("configure", "Configure a new IDP account.")
Expand Down
6 changes: 6 additions & 0 deletions pkg/cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
"github.com/versent/saml2aws/v2/pkg/prompter"
ini "gopkg.in/ini.v1"
)

Expand Down Expand Up @@ -53,6 +54,7 @@ type IDPAccount struct {
TargetURL string `ini:"target_url"`
DisableRememberDevice bool `ini:"disable_remember_device"` // used by Okta
DisableSessions bool `ini:"disable_sessions"` // used by Okta
Prompter string `ini:"prompter"`
}

func (ia IDPAccount) String() string {
Expand Down Expand Up @@ -132,6 +134,10 @@ func (ia *IDPAccount) Validate() error {
return errors.New("Profile empty in idp account")
}

if err := prompter.ValidateAndSetPrompter(ia.Prompter); err != nil {
return err
}

return nil
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type CommonFlags struct {
SAMLCacheFile string
DisableRememberDevice bool
DisableSessions bool
Prompter string
}

// LoginExecFlags flags for the Login / Exec commands
Expand Down Expand Up @@ -114,4 +115,12 @@ func ApplyFlagOverrides(commonFlags *CommonFlags, account *cfg.IDPAccount) {
if commonFlags.DisableSessions {
account.DisableSessions = commonFlags.DisableSessions
}
if commonFlags.Prompter != "" {
account.Prompter = commonFlags.Prompter
}

// select the prompter
if commonFlags.Prompter != "" {
account.Prompter = commonFlags.Prompter
}
}
122 changes: 122 additions & 0 deletions pkg/prompter/pinentry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package prompter

import (
"bufio"
"fmt"
"io"
"os/exec"
"strings"
"sync"
)

const (
defaultPinentryDialog string = "Security token [%s]"
)

// PinentryRunner is the interface for pinentry to run itself
type PinentryRunner interface {
Run(string) (string, error)
}

// RealPinentryRunner is the concrete implementation of PinentryRunner
type RealPinentryRunner struct {
PinentryBin string
}

// PinentryPrompter is a concrete implementation of the Prompter interface.
// It uses the default Cli under the hood, except for RequestSecurityCode, where
// it uses any _pinentry_ binary to capture the security code.
// Its purpose is mainly to capture the TOTP code outside of the TTY, and thus
// making it possible to use TOTP with the credential process.
// https://github.com/Versent/saml2aws#using-saml2aws-as-credential-process
type PinentryPrompter struct {
Runner PinentryRunner
DefaultPrompter Prompter
}

// NewPinentryPrompter is a factory for PinentryPrompter
func NewPinentryPrompter(bin string) *PinentryPrompter {
return &PinentryPrompter{Runner: NewRealPinentryRunner(bin), DefaultPrompter: NewCli()}
}

// NewRealPinentryRunner is a factory for RealPinentryRunner
func NewRealPinentryRunner(bin string) *RealPinentryRunner {
return &RealPinentryRunner{PinentryBin: bin}
}

// RequestSecurityCode for PinentryPrompter is creating a query for pinentry
// and sends it to the pinentry bin.
func (p *PinentryPrompter) RequestSecurityCode(pattern string) (output string) {
commandTemplate := "SETPROMPT %s\nGETPIN\n"
prompt := fmt.Sprintf(defaultPinentryDialog, pattern)
command := fmt.Sprintf(commandTemplate, prompt)
if output, err := p.Runner.Run(command); err != nil {
return ""
} else {
return output
}
}

// ChooseWithDefault is running the default CLI ChooseWithDefault
func (p *PinentryPrompter) ChooseWithDefault(prompt string, def string, choices []string) (string, error) {
return p.DefaultPrompter.ChooseWithDefault(prompt, def, choices)
}

// Choose is running the default CLI Choose
func (p *PinentryPrompter) Choose(pr string, options []string) int {
return p.DefaultPrompter.Choose(pr, options)
}

// StringRequired is runniner the default Cli StringRequired
func (p *PinentryPrompter) StringRequired(pr string) string {
return p.DefaultPrompter.StringRequired(pr)
}

// String is runniner the default Cli String
func (p *PinentryPrompter) String(pr string, defaultValue string) string {
return p.DefaultPrompter.String(pr, defaultValue)
}

// Password is runniner the default Cli Password
func (p *PinentryPrompter) Password(pr string) string {
return p.DefaultPrompter.Password(pr)
}

// Run wraps a pinentry run. It sends the query to pinentry via stdin and
// reads its stdout to determine the user PIN.
// Pinentry uses an Assuan protocol
func (r *RealPinentryRunner) Run(command string) (output string, err error) {
cmd := exec.Command(r.PinentryBin, "--ttyname", "/dev/tty")
cmd.Stdin = strings.NewReader(command)
out, _ := cmd.StdoutPipe()

wg := sync.WaitGroup{}
wg.Add(1)
go func() {
err = cmd.Run()
// fmt.Println(err)
wg.Done()
}()

output, err = ParseResults(out)
wg.Wait()
return output, err
}

// ParseResults parses the standard output of the pinentry command and determine the
// user input, or wheter the program yielded any error
func ParseResults(pinEntryOutput io.Reader) (output string, err error) {
scanner := bufio.NewScanner(pinEntryOutput)
for scanner.Scan() {
line := scanner.Text()
// fmt.Println(line)
if strings.HasPrefix(line, "D ") {
output = line[2:]
}
if strings.HasPrefix(line, "ERR ") {
return "", fmt.Errorf("Error while running pinentry: %s", line[4:])
}
}

return output, err
}
182 changes: 182 additions & 0 deletions pkg/prompter/pinentry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package prompter

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

// creates a fake runner so we can perform unit tests
type FakePinentryRunner struct {
HasRun bool
FakeOutput string
FakePinentryOutput string
PassedInput string
}

func (f *FakePinentryRunner) Run(cmd string) (string, error) {
f.PassedInput = cmd
f.HasRun = true
if f.FakeOutput != "" {
return f.FakeOutput, nil
}
if f.FakePinentryOutput != "" {
reader := strings.NewReader(f.FakePinentryOutput)
return ParseResults(reader)
}
return f.FakeOutput, nil
}

// FakeDefaultPrompter is a Mock prompter
type FakeDefaultPrompter struct {
CalledRequestSecurityCode bool
CalledChoose bool
CalledChooseWithDefault bool
CalledString bool
CalledStringRequired bool
CalledPassword bool
}

// all the functions to implement the Prompter interface
func (f *FakeDefaultPrompter) RequestSecurityCode(p string) string {
f.CalledRequestSecurityCode = true
return ""
}
func (f *FakeDefaultPrompter) Choose(p string, option []string) int {
f.CalledChoose = true
return 0
}
func (f *FakeDefaultPrompter) ChooseWithDefault(p string, d string, c []string) (string, error) {
f.CalledChooseWithDefault = true
return "", nil
}
func (f *FakeDefaultPrompter) String(p string, defaultValue string) string {
f.CalledString = true
return ""
}
func (f *FakeDefaultPrompter) StringRequired(p string) string {
f.CalledStringRequired = true
return ""
}
func (f *FakeDefaultPrompter) Password(p string) string {
f.CalledPassword = true
return ""
}

func TestValidateAndSetPrompterShouldFailWithWrongInput(t *testing.T) {

// backing up the current prompters for the other tests
oldPrompter := ActivePrompter
defer func() { ActivePrompter = oldPrompter }()

errPrompts := []string{"abcde", "invalid", "prompt", " ", "pinentryfake"}
for _, errPrompt := range errPrompts {
err := ValidateAndSetPrompter(errPrompt)
assert.Error(t, err)
}

}
func TestValidateAndSetPrompterShouldWorkWithInputForCli(t *testing.T) {

// backing up the current prompters for the other tests
oldPrompter := ActivePrompter
defer func() { ActivePrompter = oldPrompter }()

errPrompts := []string{"", "default", "survey"}
for _, errPrompt := range errPrompts {
err := ValidateAndSetPrompter(errPrompt)
assert.NoError(t, err)
assert.IsType(t, ActivePrompter, NewCli())
}

}
func TestValidateAndSetPrompterShouldWorkWithInputForPinentry(t *testing.T) {

// backing up the current prompters for the other tests
oldPrompter := ActivePrompter
defer func() { ActivePrompter = oldPrompter }()

errPrompts := []string{"pinentry", "pinentry-tty", "pinentry-mac", "pinentry-gnome3"}
for _, errPrompt := range errPrompts {
err := ValidateAndSetPrompter(errPrompt)
assert.NoError(t, err)
assert.IsType(t, ActivePrompter, &PinentryPrompter{})
}

}

func TestChecksPinentryPrompterDefault(t *testing.T) {
p := &PinentryPrompter{}
fakeDefaultPrompter := &FakeDefaultPrompter{}
p.DefaultPrompter = fakeDefaultPrompter

_ = p.Choose("random", []string{"1", "2"})
assert.True(t, fakeDefaultPrompter.CalledChoose)

_, _ = p.ChooseWithDefault("random", "random", []string{"1", "2"})
assert.True(t, fakeDefaultPrompter.CalledChooseWithDefault)

_ = p.String("random", "random")
assert.True(t, fakeDefaultPrompter.CalledString)

_ = p.StringRequired("random")
assert.True(t, fakeDefaultPrompter.CalledStringRequired)

_ = p.Password("random")
assert.True(t, fakeDefaultPrompter.CalledPassword)
}

func TestChecksPinentryPrompterCallsPinentryForRequestSecurityCode(t *testing.T) {
p := &PinentryPrompter{}
runner := &FakePinentryRunner{}
p.Runner = runner
runner.FakeOutput = "random_code"
pinentryOutput := p.RequestSecurityCode("000000")

assert.True(t, runner.HasRun)
assert.Equal(t, pinentryOutput, "random_code")
assert.Equal(t, runner.PassedInput, "SETPROMPT Security token [000000]\nGETPIN\n")

}

func TestChecksPinentryPrompterReturnsRightCodeGivenFakePinentryOutput(t *testing.T) {
p := &PinentryPrompter{}
runner := &FakePinentryRunner{}
p.Runner = runner
runner.FakePinentryOutput = "OK This line should get ignored\nOK This line should too\nD 654321\nOK Final\n"
pinentryOutput := p.RequestSecurityCode("000000")

assert.True(t, runner.HasRun)
assert.Equal(t, pinentryOutput, "654321")

}

func TestChecksPinentryPrompterReturnsNoCodeGivenErroneousFakePinentryOutput(t *testing.T) {
p := &PinentryPrompter{}
runner := &FakePinentryRunner{}
p.Runner = runner
runner.FakePinentryOutput = "OK This line should get ignored\nOK This line should too\nERR This is an error\nD 654321\nOK Final\n"
pinentryOutput := p.RequestSecurityCode("000000")

assert.True(t, runner.HasRun)
assert.Equal(t, pinentryOutput, "")
}

func TestParseOutputShouldThrowError(t *testing.T) {

input := strings.NewReader("OK Ignore this line\nERR This is an error\nD This result should be ignored\nOK this is irrelevant\n")
output, err := ParseResults(input)

assert.Error(t, err)
assert.Equal(t, output, "")
}

func TestParseOutputShouldReturnCorrectOutput(t *testing.T) {

input := strings.NewReader("OK Ignore this line\nD THISISTHERETURN\nOK this is irrelevant\n")
output, err := ParseResults(input)

assert.NoError(t, err)
assert.Equal(t, output, "THISISTHERETURN")
}
Loading

0 comments on commit f872b40

Please sign in to comment.