diff options
| author | Shulhan <ms@kilabit.info> | 2023-11-18 22:40:35 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2023-11-18 22:46:01 +0700 |
| commit | 43d39b3aa9154f68396a5bb613bc57f7ef1599e7 (patch) | |
| tree | c8b093b1bd33c571305be06ac4af11e85edd56dd | |
| parent | cbe11eddc3c2bdda7348fbe8add9a4e28443906b (diff) | |
| download | pakakeh.go-43d39b3aa9154f68396a5bb613bc57f7ef1599e7.tar.xz | |
lib/crypto: add support for reading passphrase using SSH_ASKPASS
If the library failed to changes os.Stdin to raw, it will try to use
a program defined in SSH_ASKPASS environment variable.
The SSH_ASKPASS is controlled by environment SSH_ASKPASS_REQUIRE.
- If SSH_ASKPASS_REQUIRE is empty the passphrase will read from
terminal first, if not possible then using SSH_ASKPASS program.
- If SSH_ASKPASS_REQUIRE is set to "never" the passphrase will read
from terminal only.
- If SSH_ASKPASS_REQUIRE is set to "prefer", the passphrase will read
using SSH_ASKPASS program not from terminal, but require
DISPLAY environment to be set.
- If SSH_ASKPASS_REQUIRE is set to "force", the passphrase will read
using SSH_ASKPASS program not from terminal, without checking DISPLAY
environment.
This changes affect the [ssh.NewClientInteractive] indirectly.
| -rw-r--r-- | lib/crypto/crypto.go | 128 | ||||
| -rw-r--r-- | lib/crypto/crypto_test.go | 74 | ||||
| -rwxr-xr-x | lib/crypto/testdata/askpass.sh | 3 | ||||
| -rw-r--r-- | lib/ssh/client.go | 3 |
4 files changed, 186 insertions, 22 deletions
diff --git a/lib/crypto/crypto.go b/lib/crypto/crypto.go index d2f11408..3e450ac7 100644 --- a/lib/crypto/crypto.go +++ b/lib/crypto/crypto.go @@ -7,6 +7,7 @@ package crypto import ( + "bytes" "crypto" "crypto/rsa" "errors" @@ -16,18 +17,33 @@ import ( "os" "strings" + "github.com/shuLhan/share/lib/os/exec" "golang.org/x/crypto/ssh" "golang.org/x/term" ) // ErrEmptyPassphrase returned when private key is encrypted and loaded // interactively, using [LoadPrivateKeyInteractive], but the readed -// passphrase is empty from terminal. +// passphrase is empty. // // This is to catch error "bcrypt_pbkdf: empty password" earlier that cannot -// be catched using errors.Is after [ssh.ParseRawPrivateKeyWithPassphrase]. +// be catched using [errors.Is] after +// [ssh.ParseRawPrivateKeyWithPassphrase]. var ErrEmptyPassphrase = errors.New(`empty passphrase`) +// ErrStdinPassphrase error when program cannot changes [os.Stdin] for +// reading passphrase in terminal. +// The original error message is "inappropriate ioctl for device". +var ErrStdinPassphrase = errors.New(`cannot read passhprase from stdin`) + +// List of environment variables reads when reading passphrase +// interactively. +const ( + envKeySshAskpassRequire = `SSH_ASKPASS_REQUIRE` + envKeySshAskpass = `SSH_ASKPASS` + envKeyDisplay = `DISPLAY` +) + // DecryptOaep extend the [rsa.DecryptOAEP] to make it able to decrypt a // message larger than its public modulus size. func DecryptOaep(hash hash.Hash, random io.Reader, pkey *rsa.PrivateKey, cipher, label []byte) (plain []byte, err error) { @@ -121,11 +137,30 @@ func LoadPrivateKey(file string, passphrase []byte) (pkey crypto.PrivateKey, err // LoadPrivateKeyInteractive load the private key from file. // If the private key file is encrypted, it will prompt for the passphrase -// from terminal. +// from terminal or from program defined in SSH_ASKPASS environment +// variable. // // The termrw parameter is optional, default to os.Stdin if its nil. // Its provide as reader-and-writer to prompt and read password from -// terminal; or for testing. +// terminal (or for testing). +// +// The SSH_ASKPASS is controlled by environment SSH_ASKPASS_REQUIRE. +// +// - If SSH_ASKPASS_REQUIRE is empty the passphrase will read from +// terminal first, if not possible then using SSH_ASKPASS program. +// +// - If SSH_ASKPASS_REQUIRE is set to "never", the passphrase will read +// from terminal only. +// +// - If SSH_ASKPASS_REQUIRE is set to "prefer", the passphrase will read +// using SSH_ASKPASS program not from terminal, but require +// DISPLAY environment to be set. +// +// - If SSH_ASKPASS_REQUIRE is set to "force", the passphrase will read +// using SSH_ASKPASS program not from terminal without checking DISPLAY +// environment. +// +// See ssh(1) manual page for more information. func LoadPrivateKeyInteractive(termrw io.ReadWriter, file string) (pkey crypto.PrivateKey, err error) { var ( logp = `LoadPrivateKeyInteractive` @@ -151,10 +186,56 @@ func LoadPrivateKeyInteractive(termrw io.ReadWriter, file string) (pkey crypto.P } var ( + askpassRequire = os.Getenv(envKeySshAskpassRequire) + + pass string + ) + + switch askpassRequire { + default: + // Accept empty SSH_ASKPASS_REQUIRE, "never", or other + // unknown string. + // Try to read passphrase from terminal first. + pass, err = readPassTerm(termrw, file) + if err != nil { + if !strings.Contains(err.Error(), `inappropriate ioctl`) { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + if askpassRequire == `` { + // We cannot changes the os.Stdin to raw + // terminal, try using SSH_ASKPASS program + // instead. + pass, err = sshAskpass(askpassRequire) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + } + } + + case `prefer`, `force`: + // Use SSH_ASKPASS program instead of reading from terminal. + pass, err = sshAskpass(askpassRequire) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + } + + passphrase = []byte(pass) + + pkey, err = ssh.ParseRawPrivateKeyWithPassphrase(rawpem, passphrase) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + return pkey, nil +} + +// readPassTerm read the passphrase from terminal. +func readPassTerm(termrw io.ReadWriter, file string) (pass string, err error) { + var ( prompt = fmt.Sprintf(`Enter passphrase for %q:`, file) xterm *term.Terminal - pass string ) if termrw == nil { @@ -166,7 +247,7 @@ func LoadPrivateKeyInteractive(termrw io.ReadWriter, file string) (pkey crypto.P oldState, err = term.MakeRaw(stdin) if err != nil { - return nil, fmt.Errorf(`%s: MakeRaw: %w`, logp, err) + return ``, fmt.Errorf(`MakeRaw: %w`, err) } defer term.Restore(stdin, oldState) @@ -177,18 +258,41 @@ func LoadPrivateKeyInteractive(termrw io.ReadWriter, file string) (pkey crypto.P pass, err = xterm.ReadPassword(prompt) if err != nil && !errors.Is(err, io.EOF) { - return nil, fmt.Errorf(`%s: ReadPassword: %w`, logp, err) + return ``, fmt.Errorf(`ReadPassword: %w`, err) } if len(pass) == 0 { - return nil, fmt.Errorf(`%s: %w`, logp, ErrEmptyPassphrase) + return ``, ErrEmptyPassphrase } + return pass, nil +} - passphrase = []byte(pass) +// sshAskpass get passphrase from the program defined in environment +// SSH_ASKPASS. +// This require the DISPLAY environment also be set only if +// SSH_ASKPASS_REQUIRE is set to "prefer". +func sshAskpass(askpassRequire string) (pass string, err error) { + var val string - pkey, err = ssh.ParseRawPrivateKeyWithPassphrase(rawpem, passphrase) + if askpassRequire == `prefer` { + val = os.Getenv(envKeyDisplay) + if len(val) == 0 { + return ``, ErrStdinPassphrase + } + } + + val = os.Getenv(envKeySshAskpass) + if len(val) == 0 { + return ``, ErrStdinPassphrase + } + + var stdout bytes.Buffer + + err = exec.Run(val, &stdout, os.Stderr) if err != nil { - return nil, fmt.Errorf(`%s: %w`, logp, err) + return ``, err } - return pkey, nil + pass = stdout.String() + + return pass, nil } diff --git a/lib/crypto/crypto_test.go b/lib/crypto/crypto_test.go index 4aee0a47..b069849c 100644 --- a/lib/crypto/crypto_test.go +++ b/lib/crypto/crypto_test.go @@ -6,6 +6,8 @@ import ( "crypto/rsa" "crypto/sha256" "io" + "os" + "path/filepath" "testing" "golang.org/x/crypto/ssh" @@ -78,52 +80,106 @@ func TestEncryptOaep(t *testing.T) { func TestLoadPrivateKeyInteractive(t *testing.T) { type testCase struct { + desc string file string secret string termrw io.ReadWriter expError string + + // Environment variables for testing with SSH_ASKPASS and + // SSH_ASKPASS_REQUIRE. + envDisplay string + envSshAskpass string + envSshAskpassRequire string + } + + var ( + wd string + err error + ) + + wd, err = os.Getwd() + if err != nil { + t.Fatal(err) } var ( - mockrw = mock.ReadWriter{} + mockrw = mock.ReadWriter{} + askpassProgram = filepath.Join(wd, `testdata`, `askpass.sh`) pkey crypto.PrivateKey - err error ok bool ) var cases = []testCase{{ + desc: `withValidPassphrase`, file: `testdata/openssl_rsa_pass.key`, secret: "s3cret\r\n", termrw: &mockrw, }, { + desc: `withMockedrw`, file: `testdata/openssl_rsa_pass.key`, termrw: &mockrw, expError: `LoadPrivateKeyInteractive: empty passphrase`, }, { - file: `testdata/openssl_rsa_pass.key`, - // Using nil (default to os.Stdin for termrw). - termrw: nil, - expError: `LoadPrivateKeyInteractive: MakeRaw: inappropriate ioctl for device`, + desc: `withDefaultTermrw`, + file: `testdata/openssl_rsa_pass.key`, + termrw: nil, // Using nil default to os.Stdin for termrw. + expError: `LoadPrivateKeyInteractive: cannot read passhprase from stdin`, + }, { + desc: `withAskpassRequire=prefer`, + file: `testdata/openssl_rsa_pass.key`, + envDisplay: `:0`, + envSshAskpass: askpassProgram, + envSshAskpassRequire: `prefer`, + }, { + desc: `withAskpassRequire=prefer, no DISPLAY`, + file: `testdata/openssl_rsa_pass.key`, + envSshAskpass: askpassProgram, + envSshAskpassRequire: `prefer`, + expError: `LoadPrivateKeyInteractive: cannot read passhprase from stdin`, + }, { + desc: `withAskpassRequire=prefer, empty SSH_ASKPASS`, + file: `testdata/openssl_rsa_pass.key`, + envDisplay: `:0`, + envSshAskpassRequire: `prefer`, + expError: `LoadPrivateKeyInteractive: cannot read passhprase from stdin`, + }, { + desc: `withAskpassRequire=prefer, invalid program`, + file: `testdata/openssl_rsa_pass.key`, + envDisplay: `:0`, + envSshAskpass: `/invalid/program`, + envSshAskpassRequire: `prefer`, + expError: `LoadPrivateKeyInteractive: fork/exec /invalid/program: no such file or directory`, + }, { + desc: `withAskpassRequire=force`, + file: `testdata/openssl_rsa_pass.key`, + envDisplay: `:0`, + envSshAskpass: askpassProgram, + envSshAskpassRequire: `force`, }} var c testCase for _, c = range cases { + os.Setenv(envKeyDisplay, c.envDisplay) + os.Setenv(envKeySshAskpass, c.envSshAskpass) + os.Setenv(envKeySshAskpassRequire, c.envSshAskpassRequire) + _, err = mockrw.BufRead.WriteString(c.secret) if err != nil { - t.Fatal(err) + t.Fatalf(`%s: %s`, c.desc, err) } pkey, err = LoadPrivateKeyInteractive(c.termrw, c.file) if err != nil { - test.Assert(t, `LoadPrivateKeyInteractive`, c.expError, err.Error()) + test.Assert(t, c.desc+` error`, c.expError, err.Error()) continue } _, ok = pkey.(*rsa.PrivateKey) if !ok { - test.Assert(t, `cast to *rsa.PrivateKey`, c.expError, err.Error()) + test.Assert(t, c.desc+` cast to *rsa.PrivateKey`, c.expError, err.Error()) continue } } diff --git a/lib/crypto/testdata/askpass.sh b/lib/crypto/testdata/askpass.sh new file mode 100755 index 00000000..325317e7 --- /dev/null +++ b/lib/crypto/testdata/askpass.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +echo -n s3cret diff --git a/lib/ssh/client.go b/lib/ssh/client.go index 420170c1..8311550b 100644 --- a/lib/ssh/client.go +++ b/lib/ssh/client.go @@ -50,7 +50,8 @@ type Client struct { // IdentityFile directive is specified in the Host section. // // If the IdentityFile is encrypted, it will prompt for passphrase in -// terminal. +// terminal or from program defined in SSH_ASKPASS, see +// [crypto.LoadPrivateKeyInteractive] for more information. // // The following section keys are recognized and implemented by Client, // - Hostname |
