summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2023-11-18 22:40:35 +0700
committerShulhan <ms@kilabit.info>2023-11-18 22:46:01 +0700
commit43d39b3aa9154f68396a5bb613bc57f7ef1599e7 (patch)
treec8b093b1bd33c571305be06ac4af11e85edd56dd
parentcbe11eddc3c2bdda7348fbe8add9a4e28443906b (diff)
downloadpakakeh.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.go128
-rw-r--r--lib/crypto/crypto_test.go74
-rwxr-xr-xlib/crypto/testdata/askpass.sh3
-rw-r--r--lib/ssh/client.go3
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