Skip to content

Commit f872b40

Browse files
author
Mark Wolfe
authored
Merge pull request #744 from sledigabel/add_pinentry_prompter
Adding Pinentry as a prompter
2 parents 8c1b6fe + 31ec234 commit f872b40

File tree

6 files changed

+353
-8
lines changed

6 files changed

+353
-8
lines changed

cmd/saml2aws/main.go

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func main() {
8282
app.Flag("session-duration", "The duration of your AWS Session. (env: SAML2AWS_SESSION_DURATION)").Envar("SAML2AWS_SESSION_DURATION").IntVar(&commonFlags.SessionDuration)
8383
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)
8484
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)
85+
app.Flag("prompter", "The prompter to use for user input (default, pinentry)").StringVar(&commonFlags.Prompter)
8586

8687
// `configure` command and settings
8788
cmdConfigure := app.Command("configure", "Configure a new IDP account.")

pkg/cfg/cfg.go

+6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/mitchellh/go-homedir"
88
"github.com/pkg/errors"
9+
"github.com/versent/saml2aws/v2/pkg/prompter"
910
ini "gopkg.in/ini.v1"
1011
)
1112

@@ -53,6 +54,7 @@ type IDPAccount struct {
5354
TargetURL string `ini:"target_url"`
5455
DisableRememberDevice bool `ini:"disable_remember_device"` // used by Okta
5556
DisableSessions bool `ini:"disable_sessions"` // used by Okta
57+
Prompter string `ini:"prompter"`
5658
}
5759

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

137+
if err := prompter.ValidateAndSetPrompter(ia.Prompter); err != nil {
138+
return err
139+
}
140+
135141
return nil
136142
}
137143

pkg/flags/flags.go

+9
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type CommonFlags struct {
3232
SAMLCacheFile string
3333
DisableRememberDevice bool
3434
DisableSessions bool
35+
Prompter string
3536
}
3637

3738
// LoginExecFlags flags for the Login / Exec commands
@@ -114,4 +115,12 @@ func ApplyFlagOverrides(commonFlags *CommonFlags, account *cfg.IDPAccount) {
114115
if commonFlags.DisableSessions {
115116
account.DisableSessions = commonFlags.DisableSessions
116117
}
118+
if commonFlags.Prompter != "" {
119+
account.Prompter = commonFlags.Prompter
120+
}
121+
122+
// select the prompter
123+
if commonFlags.Prompter != "" {
124+
account.Prompter = commonFlags.Prompter
125+
}
117126
}

pkg/prompter/pinentry.go

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package prompter
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"os/exec"
8+
"strings"
9+
"sync"
10+
)
11+
12+
const (
13+
defaultPinentryDialog string = "Security token [%s]"
14+
)
15+
16+
// PinentryRunner is the interface for pinentry to run itself
17+
type PinentryRunner interface {
18+
Run(string) (string, error)
19+
}
20+
21+
// RealPinentryRunner is the concrete implementation of PinentryRunner
22+
type RealPinentryRunner struct {
23+
PinentryBin string
24+
}
25+
26+
// PinentryPrompter is a concrete implementation of the Prompter interface.
27+
// It uses the default Cli under the hood, except for RequestSecurityCode, where
28+
// it uses any _pinentry_ binary to capture the security code.
29+
// Its purpose is mainly to capture the TOTP code outside of the TTY, and thus
30+
// making it possible to use TOTP with the credential process.
31+
// https://github.com/Versent/saml2aws#using-saml2aws-as-credential-process
32+
type PinentryPrompter struct {
33+
Runner PinentryRunner
34+
DefaultPrompter Prompter
35+
}
36+
37+
// NewPinentryPrompter is a factory for PinentryPrompter
38+
func NewPinentryPrompter(bin string) *PinentryPrompter {
39+
return &PinentryPrompter{Runner: NewRealPinentryRunner(bin), DefaultPrompter: NewCli()}
40+
}
41+
42+
// NewRealPinentryRunner is a factory for RealPinentryRunner
43+
func NewRealPinentryRunner(bin string) *RealPinentryRunner {
44+
return &RealPinentryRunner{PinentryBin: bin}
45+
}
46+
47+
// RequestSecurityCode for PinentryPrompter is creating a query for pinentry
48+
// and sends it to the pinentry bin.
49+
func (p *PinentryPrompter) RequestSecurityCode(pattern string) (output string) {
50+
commandTemplate := "SETPROMPT %s\nGETPIN\n"
51+
prompt := fmt.Sprintf(defaultPinentryDialog, pattern)
52+
command := fmt.Sprintf(commandTemplate, prompt)
53+
if output, err := p.Runner.Run(command); err != nil {
54+
return ""
55+
} else {
56+
return output
57+
}
58+
}
59+
60+
// ChooseWithDefault is running the default CLI ChooseWithDefault
61+
func (p *PinentryPrompter) ChooseWithDefault(prompt string, def string, choices []string) (string, error) {
62+
return p.DefaultPrompter.ChooseWithDefault(prompt, def, choices)
63+
}
64+
65+
// Choose is running the default CLI Choose
66+
func (p *PinentryPrompter) Choose(pr string, options []string) int {
67+
return p.DefaultPrompter.Choose(pr, options)
68+
}
69+
70+
// StringRequired is runniner the default Cli StringRequired
71+
func (p *PinentryPrompter) StringRequired(pr string) string {
72+
return p.DefaultPrompter.StringRequired(pr)
73+
}
74+
75+
// String is runniner the default Cli String
76+
func (p *PinentryPrompter) String(pr string, defaultValue string) string {
77+
return p.DefaultPrompter.String(pr, defaultValue)
78+
}
79+
80+
// Password is runniner the default Cli Password
81+
func (p *PinentryPrompter) Password(pr string) string {
82+
return p.DefaultPrompter.Password(pr)
83+
}
84+
85+
// Run wraps a pinentry run. It sends the query to pinentry via stdin and
86+
// reads its stdout to determine the user PIN.
87+
// Pinentry uses an Assuan protocol
88+
func (r *RealPinentryRunner) Run(command string) (output string, err error) {
89+
cmd := exec.Command(r.PinentryBin, "--ttyname", "/dev/tty")
90+
cmd.Stdin = strings.NewReader(command)
91+
out, _ := cmd.StdoutPipe()
92+
93+
wg := sync.WaitGroup{}
94+
wg.Add(1)
95+
go func() {
96+
err = cmd.Run()
97+
// fmt.Println(err)
98+
wg.Done()
99+
}()
100+
101+
output, err = ParseResults(out)
102+
wg.Wait()
103+
return output, err
104+
}
105+
106+
// ParseResults parses the standard output of the pinentry command and determine the
107+
// user input, or wheter the program yielded any error
108+
func ParseResults(pinEntryOutput io.Reader) (output string, err error) {
109+
scanner := bufio.NewScanner(pinEntryOutput)
110+
for scanner.Scan() {
111+
line := scanner.Text()
112+
// fmt.Println(line)
113+
if strings.HasPrefix(line, "D ") {
114+
output = line[2:]
115+
}
116+
if strings.HasPrefix(line, "ERR ") {
117+
return "", fmt.Errorf("Error while running pinentry: %s", line[4:])
118+
}
119+
}
120+
121+
return output, err
122+
}

pkg/prompter/pinentry_test.go

+182
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package prompter
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
// creates a fake runner so we can perform unit tests
11+
type FakePinentryRunner struct {
12+
HasRun bool
13+
FakeOutput string
14+
FakePinentryOutput string
15+
PassedInput string
16+
}
17+
18+
func (f *FakePinentryRunner) Run(cmd string) (string, error) {
19+
f.PassedInput = cmd
20+
f.HasRun = true
21+
if f.FakeOutput != "" {
22+
return f.FakeOutput, nil
23+
}
24+
if f.FakePinentryOutput != "" {
25+
reader := strings.NewReader(f.FakePinentryOutput)
26+
return ParseResults(reader)
27+
}
28+
return f.FakeOutput, nil
29+
}
30+
31+
// FakeDefaultPrompter is a Mock prompter
32+
type FakeDefaultPrompter struct {
33+
CalledRequestSecurityCode bool
34+
CalledChoose bool
35+
CalledChooseWithDefault bool
36+
CalledString bool
37+
CalledStringRequired bool
38+
CalledPassword bool
39+
}
40+
41+
// all the functions to implement the Prompter interface
42+
func (f *FakeDefaultPrompter) RequestSecurityCode(p string) string {
43+
f.CalledRequestSecurityCode = true
44+
return ""
45+
}
46+
func (f *FakeDefaultPrompter) Choose(p string, option []string) int {
47+
f.CalledChoose = true
48+
return 0
49+
}
50+
func (f *FakeDefaultPrompter) ChooseWithDefault(p string, d string, c []string) (string, error) {
51+
f.CalledChooseWithDefault = true
52+
return "", nil
53+
}
54+
func (f *FakeDefaultPrompter) String(p string, defaultValue string) string {
55+
f.CalledString = true
56+
return ""
57+
}
58+
func (f *FakeDefaultPrompter) StringRequired(p string) string {
59+
f.CalledStringRequired = true
60+
return ""
61+
}
62+
func (f *FakeDefaultPrompter) Password(p string) string {
63+
f.CalledPassword = true
64+
return ""
65+
}
66+
67+
func TestValidateAndSetPrompterShouldFailWithWrongInput(t *testing.T) {
68+
69+
// backing up the current prompters for the other tests
70+
oldPrompter := ActivePrompter
71+
defer func() { ActivePrompter = oldPrompter }()
72+
73+
errPrompts := []string{"abcde", "invalid", "prompt", " ", "pinentryfake"}
74+
for _, errPrompt := range errPrompts {
75+
err := ValidateAndSetPrompter(errPrompt)
76+
assert.Error(t, err)
77+
}
78+
79+
}
80+
func TestValidateAndSetPrompterShouldWorkWithInputForCli(t *testing.T) {
81+
82+
// backing up the current prompters for the other tests
83+
oldPrompter := ActivePrompter
84+
defer func() { ActivePrompter = oldPrompter }()
85+
86+
errPrompts := []string{"", "default", "survey"}
87+
for _, errPrompt := range errPrompts {
88+
err := ValidateAndSetPrompter(errPrompt)
89+
assert.NoError(t, err)
90+
assert.IsType(t, ActivePrompter, NewCli())
91+
}
92+
93+
}
94+
func TestValidateAndSetPrompterShouldWorkWithInputForPinentry(t *testing.T) {
95+
96+
// backing up the current prompters for the other tests
97+
oldPrompter := ActivePrompter
98+
defer func() { ActivePrompter = oldPrompter }()
99+
100+
errPrompts := []string{"pinentry", "pinentry-tty", "pinentry-mac", "pinentry-gnome3"}
101+
for _, errPrompt := range errPrompts {
102+
err := ValidateAndSetPrompter(errPrompt)
103+
assert.NoError(t, err)
104+
assert.IsType(t, ActivePrompter, &PinentryPrompter{})
105+
}
106+
107+
}
108+
109+
func TestChecksPinentryPrompterDefault(t *testing.T) {
110+
p := &PinentryPrompter{}
111+
fakeDefaultPrompter := &FakeDefaultPrompter{}
112+
p.DefaultPrompter = fakeDefaultPrompter
113+
114+
_ = p.Choose("random", []string{"1", "2"})
115+
assert.True(t, fakeDefaultPrompter.CalledChoose)
116+
117+
_, _ = p.ChooseWithDefault("random", "random", []string{"1", "2"})
118+
assert.True(t, fakeDefaultPrompter.CalledChooseWithDefault)
119+
120+
_ = p.String("random", "random")
121+
assert.True(t, fakeDefaultPrompter.CalledString)
122+
123+
_ = p.StringRequired("random")
124+
assert.True(t, fakeDefaultPrompter.CalledStringRequired)
125+
126+
_ = p.Password("random")
127+
assert.True(t, fakeDefaultPrompter.CalledPassword)
128+
}
129+
130+
func TestChecksPinentryPrompterCallsPinentryForRequestSecurityCode(t *testing.T) {
131+
p := &PinentryPrompter{}
132+
runner := &FakePinentryRunner{}
133+
p.Runner = runner
134+
runner.FakeOutput = "random_code"
135+
pinentryOutput := p.RequestSecurityCode("000000")
136+
137+
assert.True(t, runner.HasRun)
138+
assert.Equal(t, pinentryOutput, "random_code")
139+
assert.Equal(t, runner.PassedInput, "SETPROMPT Security token [000000]\nGETPIN\n")
140+
141+
}
142+
143+
func TestChecksPinentryPrompterReturnsRightCodeGivenFakePinentryOutput(t *testing.T) {
144+
p := &PinentryPrompter{}
145+
runner := &FakePinentryRunner{}
146+
p.Runner = runner
147+
runner.FakePinentryOutput = "OK This line should get ignored\nOK This line should too\nD 654321\nOK Final\n"
148+
pinentryOutput := p.RequestSecurityCode("000000")
149+
150+
assert.True(t, runner.HasRun)
151+
assert.Equal(t, pinentryOutput, "654321")
152+
153+
}
154+
155+
func TestChecksPinentryPrompterReturnsNoCodeGivenErroneousFakePinentryOutput(t *testing.T) {
156+
p := &PinentryPrompter{}
157+
runner := &FakePinentryRunner{}
158+
p.Runner = runner
159+
runner.FakePinentryOutput = "OK This line should get ignored\nOK This line should too\nERR This is an error\nD 654321\nOK Final\n"
160+
pinentryOutput := p.RequestSecurityCode("000000")
161+
162+
assert.True(t, runner.HasRun)
163+
assert.Equal(t, pinentryOutput, "")
164+
}
165+
166+
func TestParseOutputShouldThrowError(t *testing.T) {
167+
168+
input := strings.NewReader("OK Ignore this line\nERR This is an error\nD This result should be ignored\nOK this is irrelevant\n")
169+
output, err := ParseResults(input)
170+
171+
assert.Error(t, err)
172+
assert.Equal(t, output, "")
173+
}
174+
175+
func TestParseOutputShouldReturnCorrectOutput(t *testing.T) {
176+
177+
input := strings.NewReader("OK Ignore this line\nD THISISTHERETURN\nOK this is irrelevant\n")
178+
output, err := ParseResults(input)
179+
180+
assert.NoError(t, err)
181+
assert.Equal(t, output, "THISISTHERETURN")
182+
}

0 commit comments

Comments
 (0)