-
Notifications
You must be signed in to change notification settings - Fork 83
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
+1,156
−326
Merged
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 ce18ecc
go fmt
francoismichel 3bd06fb
add full server-side auth plugins support
francoismichel 99c2a14
add missing interface.go
francoismichel 4d568ed
introduce HTTP request verifiers for server-side auth plugins
francoismichel 879ecc8
fix wrong type case in HandleAuth's switch
francoismichel 2137555
make plugins registry generic & add client plugins registry
francoismichel fb29815
move auth/openid_connect.go in its own package
francoismichel 425b54b
add client pluggable config options
francoismichel dd0c4e5
implement the logic for using pluggable options
francoismichel 391361d
struct Options -> struct Config
francoismichel f52f821
add missing client/config
francoismichel e7c3a3b
fix log formatting in ssh3 client
francoismichel 1175aec
add the missing pieces to execute client-based plugins
francoismichel ad5d3ae
use plugins to perform priv/pubkey based auth and remove key-based au…
francoismichel 924140d
use the pubkey_authentication plugin by default in the ssh3 client
francoismichel 9fedcac
avoid nil map from being returned by GetConfigForHost
francoismichel 9abb691
implement server-side pubkey auth as a plugin and remove it from the …
francoismichel 8adb11a
separate client and server key-based auth packages
francoismichel 9df370c
remove pubkey auth from server's base code and retrieve it from plugins
francoismichel 0c52c1c
GetAuthMethodsFunc -> GetClientAuthMethodsFunc
francoismichel e658118
reformat comments in plugins' interface.go
francoismichel e21ec78
add doc comments in plugins' plugins.go
francoismichel 0ec608e
Update auth/plugins/pubkey_authentication/client/privkey_auth.go
francoismichel 5bc6957
Update auth/interface.go
francoismichel 1b6efca
address ethan's review
francoismichel 5ec7d5e
better doc
francoismichel c4b41a4
Merge remote-tracking branch 'origin/main' into plugins
francoismichel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
package auth | ||
package oidc | ||
|
||
import ( | ||
"context" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
202
auth/plugins/pubkey_authentication/client/privkey_auth.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} | ||
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 | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.