diff --git a/option_test.go b/option_test.go index 07f9704..a2ec887 100644 --- a/option_test.go +++ b/option_test.go @@ -1,6 +1,7 @@ package wish import ( + "strings" "testing" "time" @@ -19,6 +20,54 @@ func TestWithMaxTimeout(t *testing.T) { requireEqual(t, time.Second, s.MaxTimeout) } +func TestParseAuthorizedKeys(t *testing.T) { + t.Run("valid", func(t *testing.T) { + keys, err := parseAuthorizedKeys("testdata/authorized_keys") + requireNoError(t, err) + requireEqual(t, 6, len(keys)) + }) + + t.Run("invalid", func(t *testing.T) { + keys, err := parseAuthorizedKeys("testdata/invalid_authorized_keys") + requireEqual(t, `failed to parse "testdata/invalid_authorized_keys": ssh: no key found`, err.Error()) + requireEqual(t, 0, len(keys)) + }) + + t.Run("file not found", func(t *testing.T) { + keys, err := parseAuthorizedKeys("testdata/nope_authorized_keys") + requireEqual(t, `failed to parse "testdata/nope_authorized_keys": open testdata/nope_authorized_keys: no such file or directory`, err.Error()) + requireEqual(t, 0, len(keys)) + }) +} + +func TestWithAuthorizedKeys(t *testing.T) { + t.Run("valid", func(t *testing.T) { + s := ssh.Server{} + requireNoError(t, WithAuthorizedKeys("testdata/authorized_keys")(&s)) + + for key, authorize := range map[string]bool{ + `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH k3@test`: true, + `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOhsthN+zSFSJF7V2HFSO4+2OJYRghuAA43CIbVyvzF8 k7@test`: false, + } { + parts := strings.Fields(key) + t.Run(parts[len(parts)-1], func(t *testing.T) { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) + requireNoError(t, err) + requireEqual(t, authorize, s.PublicKeyHandler(nil, key)) + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + s := ssh.Server{} + requireEqual( + t, + `failed to parse "testdata/invalid_authorized_keys": ssh: no key found`, + WithAuthorizedKeys("testdata/invalid_authorized_keys")(&s).Error(), + ) + }) +} + func requireEqual(tb testing.TB, a, b interface{}) { tb.Helper() if a != b { diff --git a/options.go b/options.go index 9f7b2b1..efe88a6 100644 --- a/options.go +++ b/options.go @@ -1,6 +1,10 @@ package wish import ( + "bufio" + "errors" + "fmt" + "io" "os" "path/filepath" "strings" @@ -63,6 +67,51 @@ func WithHostKeyPEM(pem []byte) ssh.Option { return ssh.HostKeyPEM(pem) } +// WithAuthorizedKeys allows to use a SSH authorized_keys file to allowlist users. +func WithAuthorizedKeys(path string) ssh.Option { + return func(s *ssh.Server) error { + keys, err := parseAuthorizedKeys(path) + if err != nil { + return err + } + return WithPublicKeyAuth(func(_ ssh.Context, key ssh.PublicKey) bool { + for _, upk := range keys { + if ssh.KeysEqual(upk, key) { + return true + } + } + return false + })(s) + } +} + +func parseAuthorizedKeys(path string) ([]ssh.PublicKey, error) { + var keys []ssh.PublicKey + + f, err := os.Open(path) + if err != nil { + return keys, fmt.Errorf("failed to parse %q: %w", path, err) + } + defer f.Close() // nolint: errcheck + + rd := bufio.NewReader(f) + for { + line, _, err := rd.ReadLine() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return keys, fmt.Errorf("failed to parse %q: %w", path, err) + } + upk, _, _, _, err := ssh.ParseAuthorizedKey(line) + if err != nil { + return keys, fmt.Errorf("failed to parse %q: %w", path, err) + } + keys = append(keys, upk) + } + return keys, nil +} + // WithPublicKeyAuth returns an ssh.Option that sets the public key auth handler. func WithPublicKeyAuth(h ssh.PublicKeyHandler) ssh.Option { return ssh.PublicKeyAuth(h) diff --git a/testdata/authorized_keys b/testdata/authorized_keys new file mode 100644 index 0000000..2f08859 --- /dev/null +++ b/testdata/authorized_keys @@ -0,0 +1,6 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM6LN9MSONoI2Dak7GSAy1vTY92NcioIuZqBnk0xmYR2 k1@test +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQChxV3pJRnXP7crH+4xxH8skCF/Bs8JX8VTjlS4dpLYzXMUcr0ls0DVwgIkIHvXQtqhR4ymgzciUNTTTYGPLAsda47MVqChO2Kxb+I215ApOmMt11lLX6l1Mp7xO35BYR+jC+s4H8VcespUQbWvASHKGZvhD1cri/FttjdCVs7Gqz7U5Cpo+Ym7UZ6TSiBmEd7zQkg4gR1uR4K8/5oJGpaQDDZr/QZJDGat//qvMAKtPkxomYVzHPnflFdsUIwMJHVver+JqKTMEZm2aDrOji4KpHosvfcbmIlx04N99TdT/0oNIQR1tpUsT/kdc44AqKsKUt7Os6kwYiDrjQlVIjpXPTCrdddgnl+/otH7pFsgVUjgCXz6lvhmV5HYhbJM45UEeDrmfxB0wLhC5J4fmvu4EcJtvO7vgg5PD51NpN4iFdUSj91fjskrLsbI+Do+KjcxhOdQxglZ6JwUV3ljFpPjINyawCveba9DRXO7CL/gLgwEwuIU8HXrgKFSXATSWsk= k4@test +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCdJkpQAr3zhC+grKMexj8zgJIuAQ/2LR59RvXemEAovd671Et356cmHnCDmUvUlH/70xQdyL3n68tzu2ZEzKheQP5vz05CAFXTi7rlMvhtz632mLMPlU3lGuP+A6rzqNSnTtrIa2Q3Fe2ir6N+ad782J8g6frGJaVfA/G7j/M1JwyDJWzUS3HvDHDO+qFze71h0/o9W1+VoRaSfD67BzPQumkEkt/CilSPU8VKRP3q/FIeIrgTBhNh17SX/qlnyrJipDTF1QtXUOK4H5TsEE0S13z8a4Wo37kRWQPxdjWyfX9tBjsN86n+R7OGSXXdi10n9THrisdgx2GKsk1HjY+u5YlDpDysFLBs6j4nWeTxnrjgx6HUqvMk3mdqrAKHTglt34OUQtB463GMgCW85w+ni8ebPKlt5YQsXalilcoI4K7fakyXe+o9Y0sCwE3SLXEJhtd/Esz1pVzvMBCshpRknBPFh/gs/i1YuL0SJqI2BGBFs0d/ARwqUQSoXXBTJPc= k5@test +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ7InQIj/ROngoWWb6kXTcTJd8+u5skDfGm8JJxRugMB k2@test +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDgteu96TZLd3iG11D5NqBsQRvhW2I6iD/ycwOiWFjFyv4MAHaDFiIazbeQSbi++1+5vspeNuv9AKJFgG0SpnjMLQM0rJb5DIsuRxGOAS/oh82yNCxYcW2+eXcqUDL4V+fZ6eIqtSIBrPQY89/CbZ4nFtw7+941gmFa2+7Wj9vLk4GTiyu/jQsbGnAZUCMvce1jFZ9XDMYSYzXEtkqhBT6eYDd7xMQejovszJfPqlKDxpMZxpaDsQGf+00IJPZUUxkX62eAmrlX1q4XO+m2zIjGpf/gdNKHEMXQrvBWdvwg0rat2i+PCW4Rbwx7wHBBWPRqEPjcVTfwvOWoZGGU3TSX8M7Gcj+ZvAD/uV7DWcNi61Obtw/6PXYvKFZWcZ1sHxUTI84CUcVLSL55hOtJqCuJXmUdKdBcJLyo3NValIjIZn+ljn6biVAr00nGo06nO4j2eTE2ZLOZFEB6rHuf1iaT18EiJgnJGrB7HY4+KUoUIzmvzQrKxxLbIe957hnx+TE= k6@test +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH k3@test diff --git a/testdata/invalid_authorized_keys b/testdata/invalid_authorized_keys new file mode 100644 index 0000000..55dcf69 --- /dev/null +++ b/testdata/invalid_authorized_keys @@ -0,0 +1 @@ +ssh-nope nopenopenope k1@test