merge totp to workingdir (#476)

* merge totp to workingdir

* Refactor .goreleaser.yaml to remove plugin_totp build configuration
This commit is contained in:
Boshi Lian 2024-10-28 01:09:28 -07:00 committed by GitHub
parent 0e6168a6b8
commit 23e18f4f7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 669 additions and 791 deletions

View file

@ -1,166 +0,0 @@
package workingdir
import (
"bufio"
"crypto/subtle"
"fmt"
"os"
"path"
"regexp"
"strings"
"github.com/tg123/sshpiper/libplugin"
"golang.org/x/crypto/ssh"
log "github.com/sirupsen/logrus"
)
type Workingdir struct {
Path string
NoCheckPerm bool
Strict bool
}
var (
usernameRule *regexp.Regexp
)
const (
userAuthorizedKeysFile = "authorized_keys"
userKeyFile = "id_rsa"
userUpstreamFile = "sshpiper_upstream"
userKnownHosts = "known_hosts"
)
func init() {
// Base username validation on Debians default: https://sources.debian.net/src/adduser/3.113%2Bnmu3/adduser.conf/#L85
// -> NAME_REGEX="^[a-z][-a-z0-9_]*\$"
// The length is limited to 32 characters. See man 8 useradd: https://linux.die.net/man/8/useradd
usernameRule, _ = regexp.Compile("^[a-z_][-a-z0-9_]{0,31}$")
}
func IsUsernameSecure(user string) bool {
return usernameRule.MatchString(user)
}
func (w *Workingdir) Mapkey(pub []byte) ([]byte, error) {
var rest []byte
rest, err := w.Readfile(userAuthorizedKeysFile)
if err != nil {
return nil, err
}
var authedPubkey ssh.PublicKey
for len(rest) > 0 {
authedPubkey, _, _, rest, err = ssh.ParseAuthorizedKey(rest)
if err != nil {
return nil, err
}
if subtle.ConstantTimeCompare(authedPubkey.Marshal(), pub) == 1 {
log.Infof("found mapping key %v", w.fullpath(userKeyFile))
return w.Readfile(userKeyFile)
}
}
return nil, fmt.Errorf("no matching key found")
}
func (w *Workingdir) CreateUpstream() (*libplugin.Upstream, error) {
data, err := w.Readfile(userUpstreamFile)
if err != nil {
return nil, err
}
host, port, user, err := parseUpstreamFile(string(data))
if err != nil {
return nil, err
}
return &libplugin.Upstream{
Host: host,
Port: int32(port),
UserName: user,
IgnoreHostKey: !w.Strict,
}, nil
}
func (w *Workingdir) VerifyHostKey(hostname, netaddr string, key []byte) error {
if !w.Strict {
return nil
}
f, err := os.Open(w.fullpath(userKnownHosts))
if err != nil {
return err
}
defer f.Close()
return libplugin.VerifyHostKeyFromKnownHosts(f, hostname, netaddr, key)
}
func (w *Workingdir) checkPerm(file string) error {
filename := path.Join(w.Path, file)
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return err
}
if w.NoCheckPerm {
return nil
}
if fi.Mode().Perm()&0077 != 0 {
return fmt.Errorf("%v's perm is too open", filename)
}
return nil
}
func (w *Workingdir) fullpath(file string) string {
return path.Join(w.Path, file)
}
func (w *Workingdir) Readfile(file string) ([]byte, error) {
if err := w.checkPerm(file); err != nil {
return nil, err
}
return os.ReadFile(w.fullpath(file))
}
func parseUpstreamFile(data string) (host string, port int, user string, err error) {
r := bufio.NewReader(strings.NewReader(data))
for {
host, err = r.ReadString('\n')
if err != nil {
break
}
host = strings.TrimSpace(host)
if host != "" && host[0] != '#' {
break
}
}
t := strings.SplitN(host, "@", 2)
if len(t) > 1 {
user = t[0]
host = t[1]
}
host, port, err = libplugin.SplitHostPortForSSH(host)
return
}

View file

@ -1,18 +0,0 @@
# TOTP Working Directory plugin for sshpiperd
Add TOTP 2FA to ssh, compatible with all [RFC6238](https://datatracker.ietf.org/doc/html/rfc6238) authenticator, for example: `google authenticator`, `azure authenticator`.
the plugin is load `totp` in working directory defined in [workingdir](../workingdir/) plugin.
## Usage
```
./sshpiperd ./totp -- ./workingdir
```
the secret should be stored in `totp` file in working directory.
for example:
```
/var/sshpiper/username/totp
```

View file

@ -1,95 +0,0 @@
//go:build full
package main
import (
"fmt"
"path"
"strings"
"github.com/pquerna/otp/totp"
"github.com/tg123/sshpiper/libplugin"
"github.com/tg123/sshpiper/plugin/internal/workingdir"
"github.com/urfave/cli/v2"
)
// type secretLoader struct {
// }
// func (s *secretLoader) Load(user string) (string, error) {
// return "", nil
// }
// TODO remove dup code
func createWorkingdir(c *cli.Context, user string) (*workingdir.Workingdir, error) {
if !c.Bool("allow-baduser-name") {
if !workingdir.IsUsernameSecure(user) {
return nil, fmt.Errorf("bad username: %s", user)
}
}
root := c.String("root")
return &workingdir.Workingdir{
Path: path.Join(root, user),
NoCheckPerm: c.Bool("no-check-perm"),
Strict: c.Bool("strict-hostkey"),
}, nil
}
func main() {
libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{
Name: "totp",
Usage: "sshpiperd totp 2FA authentication, workingdir based",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "root",
Usage: "path to root working directory",
Value: "/var/sshpiper",
EnvVars: []string{"SSHPIPERD_WORKINGDIR_ROOT"},
},
&cli.BoolFlag{
Name: "allow-baduser-name",
Usage: "allow bad username",
EnvVars: []string{"SSHPIPERD_WORKINGDIR_ALLOWBADUSERNAME"},
},
&cli.BoolFlag{
Name: "no-check-perm",
Usage: "disable 0400 checking",
EnvVars: []string{"SSHPIPERD_WORKINGDIR_NOCHECKPERM"},
},
},
CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) {
return &libplugin.SshPiperPluginConfig{
KeyboardInteractiveCallback: func(conn libplugin.ConnMetadata, client libplugin.KeyboardInteractiveChallenge) (*libplugin.Upstream, error) {
w, err := createWorkingdir(c, conn.User())
if err != nil {
return nil, err
}
secret, err := w.Readfile("totp")
if err != nil {
return nil, err
}
for {
passcode, err := client("", "", "Authentication code:", true)
if err != nil {
return nil, err
}
if totp.Validate(passcode, strings.TrimSpace(string(secret))) {
return &libplugin.Upstream{
Auth: libplugin.CreateNextPluginAuth(nil),
}, nil
}
_, _ = client("", "Wrong code, please try again", "", false)
}
},
}, nil
},
})
}

View file

@ -89,6 +89,16 @@ google.com:12345
├── linode....
```
## TOTP
`--check-totp` will check the TOTP 2FA before connecting to the upstream, compatible with all [RFC6238](https://datatracker.ietf.org/doc/html/rfc6238) authenticator, for example: `google authenticator`, `azure authenticator`.
the secret should be stored in `totp` file in working directory.
for example:
```
/var/sshpiper/username/totp
```
## FAQ
* Q: why sshpiperd still asks for password even I disabled password auth in upstream (different behavior from `v0`)

View file

@ -1,6 +1,11 @@
package main
import (
"fmt"
"path"
"strings"
"github.com/pquerna/otp/totp"
"github.com/tg123/sshpiper/libplugin"
"github.com/urfave/cli/v2"
)
@ -42,6 +47,11 @@ func main() {
Usage: "search subdirectories under user directory for upsteam",
EnvVars: []string{"SSHPIPERD_WORKINGDIR_RECURSIVESEARCH"},
},
&cli.BoolFlag{
Name: "check-totp",
Usage: "check totp code for 2FA, totp file should be in user directory named `totp`",
EnvVars: []string{"SSHPIPERD_WORKINGDIR_CHECKTOTP"},
},
},
CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) {
@ -54,15 +64,66 @@ func main() {
recursiveSearch: c.Bool("recursive-search"),
}
checktotp := c.Bool("check-totp")
skel := libplugin.NewSkelPlugin(fac.listPipe)
config := skel.CreateConfig()
config.NextAuthMethodsCallback = func(_ libplugin.ConnMetadata) ([]string, error) {
if fac.noPasswordAuth {
return []string{"publickey"}, nil
config.NextAuthMethodsCallback = func(conn libplugin.ConnMetadata) ([]string, error) {
auth := []string{"publickey"}
if !fac.noPasswordAuth {
auth = append(auth, "password")
}
return []string{"password", "publickey"}, nil
if checktotp {
if conn.GetMeta("totp") != "checked" {
auth = []string{"keyboard-interactive"}
}
}
return auth, nil
}
config.KeyboardInteractiveCallback = func(conn libplugin.ConnMetadata, client libplugin.KeyboardInteractiveChallenge) (*libplugin.Upstream, error) {
user := conn.User()
if !fac.allowBadUsername {
if !isUsernameSecure(user) {
return nil, fmt.Errorf("bad username: %s", user)
}
}
w := &workingdir{
Path: path.Join(fac.root, conn.User()),
NoCheckPerm: fac.noCheckPerm,
}
secret, err := w.Readfile("totp")
if err != nil {
return nil, err
}
for {
passcode, err := client("", "", "Authentication code:", true)
if err != nil {
return nil, err
}
if totp.Validate(passcode, strings.TrimSpace(string(secret))) {
return &libplugin.Upstream{
Auth: libplugin.CreateRetryCurrentPluginAuth(map[string]string{
"totp": "checked",
}),
}, nil
}
_, _ = client("", "Wrong code, please try again", "", false)
}
}
return config, nil
},
})