Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugins system for authentication #135

Merged
merged 28 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7b69e97
add server-side plugins registry
francoismichel Mar 25, 2024
ce18ecc
go fmt
francoismichel Mar 25, 2024
3bd06fb
add full server-side auth plugins support
francoismichel Mar 26, 2024
99c2a14
add missing interface.go
francoismichel Mar 26, 2024
4d568ed
introduce HTTP request verifiers for server-side auth plugins
francoismichel Mar 26, 2024
879ecc8
fix wrong type case in HandleAuth's switch
francoismichel Mar 26, 2024
2137555
make plugins registry generic & add client plugins registry
francoismichel Mar 27, 2024
fb29815
move auth/openid_connect.go in its own package
francoismichel Mar 27, 2024
425b54b
add client pluggable config options
francoismichel Apr 16, 2024
dd0c4e5
implement the logic for using pluggable options
francoismichel Apr 17, 2024
391361d
struct Options -> struct Config
francoismichel Apr 17, 2024
f52f821
add missing client/config
francoismichel Apr 17, 2024
e7c3a3b
fix log formatting in ssh3 client
francoismichel Apr 17, 2024
1175aec
add the missing pieces to execute client-based plugins
francoismichel Apr 22, 2024
ad5d3ae
use plugins to perform priv/pubkey based auth and remove key-based au…
francoismichel Apr 22, 2024
924140d
use the pubkey_authentication plugin by default in the ssh3 client
francoismichel Apr 22, 2024
9fedcac
avoid nil map from being returned by GetConfigForHost
francoismichel Apr 23, 2024
9abb691
implement server-side pubkey auth as a plugin and remove it from the …
francoismichel Apr 23, 2024
8adb11a
separate client and server key-based auth packages
francoismichel Apr 23, 2024
9df370c
remove pubkey auth from server's base code and retrieve it from plugins
francoismichel Apr 23, 2024
0c52c1c
GetAuthMethodsFunc -> GetClientAuthMethodsFunc
francoismichel Apr 23, 2024
e658118
reformat comments in plugins' interface.go
francoismichel Apr 23, 2024
e21ec78
add doc comments in plugins' plugins.go
francoismichel Apr 23, 2024
0ec608e
Update auth/plugins/pubkey_authentication/client/privkey_auth.go
francoismichel May 31, 2024
5bc6957
Update auth/interface.go
francoismichel May 31, 2024
1b6efca
address ethan's review
francoismichel May 31, 2024
5ec7d5e
better doc
francoismichel Aug 1, 2024
c4b41a4
Merge remote-tracking branch 'origin/main' into plugins
francoismichel Aug 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions auth/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package auth

import (
"net/http"

"github.com/francoismichel/ssh3"
client_config "github.com/francoismichel/ssh3/client/config"
"github.com/quic-go/quic-go/http3"
"golang.org/x/crypto/ssh/agent"
)

/////////////////////////////////////
// Server auth plugins //
/////////////////////////////////////

// In ssh3, authorized_keys are replaced by authorized_identities where a use can specify classical
// public keys as well as other authentication and authorization methods such as OAUTH2 and SAML 2.0
type RequestIdentityVerifier interface {
Verify(request *http.Request, base64ConversationID string) bool
}

// parses an AuthorizedIdentity line (`identityStr`). Returns a new Identity and a nil error if the
// line was successfully parsed. Returns a nil identity and a nil error if the line format is unknown
// to the plugin. Returns a non-nil error if any other error that is worth to be logged occurs.
//
// plugins are currently a single function so that they are completely stateless
type ServerAuthPlugin func(username string, identityStr string) (RequestIdentityVerifier, error)

/////////////////////////////////////
// Client auth plugins //
/////////////////////////////////////

// returns all the suitable authentication methods to be tried against the server in the form
// of a slice of ClientAuthMethod. Every ClientAuthMethod will have the opportunity to prepare
// an HTTP request with authentication material to startup an SSH3 conversation. For instance,
// for pubkey authentication using the private key files on the filesystem, the
// GetClientAuthMethodsFunc can return a slice containing one ClientAuthMethod for
// each private key file it wants to try.
// if no SSH agent socket if found, sshAgent is nil
type GetClientAuthMethodsFunc func(request *http.Request, sshAgent agent.ExtendedAgent, clientConfig *client_config.Config, roundTripper *http3.RoundTripper) ([]ClientAuthMethod, error)

type ClientAuthMethod interface {
// PrepareRequestForAuth updated the provided request with the needed headers
// for authentication.
// The method must not alter the request method (must always be CONNECT) nor the
// Host/:origin, User-Agent or :path headers.
// The agent is the connected SSH agent if it exists, nil otherwise
// The provided roundTripper can be used to perform requests with the server to prepare
// the authentication process.
// username is the username to authenticate
// conversation is the Conversation we want to establish
PrepareRequestForAuth(request *http.Request, sshAgent agent.ExtendedAgent, roundTripper *http3.RoundTripper, username string, conversation *ssh3.Conversation) error
}

type ClientAuthPlugin struct {
// A plugin can define one or more new SSH3 config options.
// A new option is defined by providing a dedicated option parser.
// The key in PluginOptions must be a unique name for each option
// and must not conflict with any existing option
// (good practice: "<your_repo_name>[-<option_name>]")
PluginOptions map[client_config.OptionName]client_config.OptionParser

PluginFunc GetClientAuthMethodsFunc
}
2 changes: 1 addition & 1 deletion auth/openid_connect.go → auth/oidc/openid_connect.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package auth
package oidc

import (
"context"
Expand Down
20 changes: 20 additions & 0 deletions auth/plugins/plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package plugins

import (
"github.com/francoismichel/ssh3/auth"
"github.com/francoismichel/ssh3/internal"
)

// Each new plugin package must define an init() method (see https://go.dev/doc/effective_go#init)
// in which they register one or more authentication plugins by calling either RegisterServerAuthPlugin()
// for server-side auth plugins or RegisterClientAuthPlugin() for client-side auth plugins.

// Registers a new server-side auth plugin
func RegisterServerAuthPlugin(name string, plugin auth.ServerAuthPlugin) error {
return internal.RegisterServerAuthPlugin(name, plugin)
}

// Registers a new client-side auth plugin
func RegisterClientAuthPlugin(name string, plugin auth.ClientAuthPlugin) error {
return internal.RegisterClientAuthPlugin(name, plugin)
}
202 changes: 202 additions & 0 deletions auth/plugins/pubkey_authentication/client/privkey_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package client_pubkey_authentication

import (
"bytes"
"crypto"
"fmt"
"net/http"
"os"
"syscall"

"github.com/francoismichel/ssh3"
"github.com/francoismichel/ssh3/auth"
"github.com/francoismichel/ssh3/auth/plugins"
"github.com/francoismichel/ssh3/client/config"
"github.com/francoismichel/ssh3/util"
"github.com/golang-jwt/jwt/v5"
"github.com/quic-go/quic-go/http3"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/term"
)

func init() {
plugin := auth.ClientAuthPlugin{
PluginOptions: map[config.OptionName]config.OptionParser{PRIVKEY_OPTION_NAME: &PrivkeyOptionParser{}},
PluginFunc: privkeyPluginFunc,
}
plugins.RegisterClientAuthPlugin("privkey_auth", plugin)
}

const PRIVKEY_OPTION_NAME = "github.com/francoismichel/ssh3-privkey_auth"

// implements client-side pubkey-based authentication

type PrivkeyAuthOption struct {
filenames []string
}

func (o *PrivkeyAuthOption) Filenames() []string {
return o.filenames
}

type PrivkeyOptionParser struct{}

// FlagName implements config.CLIOptionParser.
func (*PrivkeyOptionParser) FlagName() string {
return "privkey"
}

// IsBoolFlag implements config.CLIOptionParser.
func (*PrivkeyOptionParser) IsBoolFlag() bool {
return false
}

// OptionConfigName implements config.OptionParser.
func (*PrivkeyOptionParser) OptionConfigName() string {
return "IdentityFile"
}

// Parse implements config.OptionParser.
func (*PrivkeyOptionParser) Parse(values []string) (config.Option, error) {
return &PrivkeyAuthOption{
filenames: values,
}, nil
}

// Usage implements config.CLIOptionParser.
func (*PrivkeyOptionParser) Usage() string {
return "private key file"
}

var _ config.CLIOptionParser = &PrivkeyOptionParser{}

type PrivkeyFileAuthMethod struct {
filename string
passphrase *string
}

func NewPrivkeyFileAuthMethod(filename string) *PrivkeyFileAuthMethod {
return &PrivkeyFileAuthMethod{
filename: util.ExpandTildeWithHomeDir(filename),
}
}

func (m *PrivkeyFileAuthMethod) Filename() string {
return m.filename
}

func (m *PrivkeyFileAuthMethod) getCryptoMaterial() (crypto.Signer, jwt.SigningMethod, error) {

keyBytes, err := os.ReadFile(m.filename)
if err != nil {
return nil, nil, err
}
var cryptoSigner crypto.Signer
var signer interface{}

var ok bool
if m.passphrase == nil {
signer, err = ssh.ParseRawPrivateKey(keyBytes)
} else {
signer, err = ssh.ParseRawPrivateKeyWithPassphrase(keyBytes, []byte(*m.passphrase))
}
if err != nil {
return nil, nil, err
}
// transform the abstract type into a crypto.Signer that can be used with the jwt lib
if cryptoSigner, ok = signer.(crypto.Signer); !ok {
return nil, nil, fmt.Errorf("the provided key file does not result in a crypto.Signer type")
}
signingMethod, err := util.JWTSigningMethodFromCryptoPubkey(cryptoSigner.Public())
if err != nil {
return nil, nil, err
}
return cryptoSigner, signingMethod, nil
}

// PrepareRequestForAuth implements auth.ClientAuthMethod.
func (m *PrivkeyFileAuthMethod) PrepareRequestForAuth(request *http.Request, sshAgent agent.ExtendedAgent, roundTripper *http3.RoundTripper, username string, conversation *ssh3.Conversation) error {
log.Debug().Msgf("try file-based privkey auth using file %s", m.Filename())
var jwtBearerKey any
jwtBearerKey, signingMethod, err := m.getCryptoMaterial()
// could not identify without passphrase, try agent authentication by using the key's public key
if passphraseErr, ok := err.(*ssh.PassphraseMissingError); ok {
// the pubkey may be contained in the privkey file
pubkey := passphraseErr.PublicKey
if pubkey == nil {
// if it is not the case, try to find a .pub equivalent, like OpenSSH does
pubkeyBytes, err := os.ReadFile(fmt.Sprintf("%s.pub", m.Filename()))
if err == nil {
filePubkey, _, _, _, err := ssh.ParseAuthorizedKey(pubkeyBytes)
if err == nil {
pubkey = filePubkey
} else {
log.Warn().Msgf("an error happened when trying to parse the %s.pub file for agent-based authentication: %s", m.Filename(), err)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to debug log the err pubkeyBytes, err := os.ReadFile(fmt.Sprintf("%s.pub", m.Filename())) in case the error isn't just "file not found"? Maybe someone messed up the permissions and made it write-only or the system ran out of file descriptors?

In the case of ssh.ParseAuthorizedKey this means we found a matching .pub file and the file is somehow wrong. That probably means the user did something wrong in creating it. Might save a user sometime if you print a warning to the console.

Copy link
Owner Author

@francoismichel francoismichel May 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, thanks ! I think I'll put it at Debug level though since it is happening in an optional mechanism searching for the .pub counterpart of the key, which may simply be a different file for whatever reason.

}
var agentKeys []*agent.Key
if sshAgent != nil {
agentKeys, err = sshAgent.List()
if err != nil {
log.Warn().Msgf("error when listing SSH agent keys: %s", err)
err = nil
agentKeys = nil
}
}
// now, try to see of the agent manages this key
if pubkey != nil {
for _, agentKey := range agentKeys {
if bytes.Equal(agentKey.Marshal(), pubkey.Marshal()) {
log.Debug().Msgf("found key in agent: %s, switch to agent-based pubkey auth", agentKey)
pubkeyAuthMethod := NewPubkeyAuthMethod(agentKey)
// handle that using the public key auth plugin
return pubkeyAuthMethod.PrepareRequestForAuth(request, sshAgent, roundTripper, username, conversation)
}
}
}

// key not handled by agent, let's try to decrypt it ourselves
fmt.Printf("passphrase for private key stored in %s:", m.Filename())
var passphraseBytes []byte
passphraseBytes, err = term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
log.Error().Msgf("could not get passphrase: %s", err)
return err
}
passphrase := string(passphraseBytes)
m.passphrase = &passphrase
jwtBearerKey, signingMethod, err = m.getCryptoMaterial()
if err != nil {
log.Error().Msgf("could not load private key: %s", err)
return err
}
} else if err != nil {
log.Warn().Msgf("Could not load private key: %s", err)
}

bearerToken, err := ssh3.BuildJWTBearerToken(signingMethod, jwtBearerKey, username, conversation)
if err != nil {
return err
}
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", bearerToken))
return nil
}

var _ auth.ClientAuthMethod = &PrivkeyFileAuthMethod{}

var privkeyPluginFunc auth.GetClientAuthMethodsFunc = func(request *http.Request, sshAgent agent.ExtendedAgent, clientConfig *config.Config, roundTripper *http3.RoundTripper) ([]auth.ClientAuthMethod, error) {
for _, opt := range clientConfig.Options() {
if o, ok := opt.(*PrivkeyAuthOption); ok {
var methods []auth.ClientAuthMethod
for _, filename := range o.Filenames() {
methods = append(methods, &PrivkeyFileAuthMethod{filename: filename})
}
return methods, nil
}
}
return nil, nil
}
Loading