ssh/tailssh: accept passwords and public keys#14970
Conversation
c4fa9d9 to
d54f95c
Compare
ssh/tailssh/tailssh.go
Outdated
| }, | ||
| NoClientAuth: true, // required for the NoClientAuthCallback to run | ||
| NoClientAuthCallback: func(cm gossh.ConnMetadata) (*gossh.Permissions, error) { | ||
| c.logf("ZZZZ NoClientAuthCallback") |
d54f95c to
7b22f56
Compare
29372a9 to
dd7ce90
Compare
ssh/tailssh/tailssh.go
Outdated
| } | ||
|
|
||
| if !c.clientAuthAttempted.CompareAndSwap(false, true) { | ||
| panic("clientAuth unexpectedly called twice") |
There was a problem hiding this comment.
can't they call NoClientAuthCallback and then later password or PublicKeyCallback?
There was a problem hiding this comment.
when any of the callbacks returns a nil error, ssh.Server will stop the auth loop and shouldn't call any other callbacks: https://github.com/golang/crypto/blob/master/ssh/server.go#L798-L800
There was a problem hiding this comment.
oh, and if they hit the +password hack, we swap the password callback for this one:
tailscale/ssh/tailssh/tailssh.go
Lines 387 to 395 in dd7ce90
There was a problem hiding this comment.
So, this turned out to be a bit more complicated.
In cases where authentication succeeds, we'll only hit clientAuth() once. However, in cases where authentication fails, the client may try multiple authentication methods, for example none -> publickey -> password. Since we don't actually care about the content of the auth method, if the first method fails to authenticate, all subsequent ones will too.
What I'm doing with the new logic is tracking whether we've already attempted authentication. If so, we immediately return an error. Note that this is deliberately not a banner error, since we don't want to repeatedly signal the same failure to the client.
There was a problem hiding this comment.
See here for the controlling loop. By default, the SSH server will try authentication up to 6 times. Each time, it reads an auth request from the client and then tries to do the authentication. If it succeeds, it breaks out of the loop, and we won't get any subsequent authentication attempts.
There was a problem hiding this comment.
This behavior seems to be spec compliant. RFC4252 Section 5 says
While there is usually little point for clients to send requests that
the server does not list as acceptable, sending such requests is not
an error, and the server SHOULD simply reject requests that it does
not recognize.
Also
the client MAY at any time continue with a new
SSH_MSG_USERAUTH_REQUEST message, in which case the server MUST
abandon the previous authentication attempt and continue with the new
one.
dd7ce90 to
3288ffb
Compare
| from paramiko.ecdsakey import ECDSAKey | ||
| client = pm.SSHClient() | ||
| client.set_missing_host_key_policy(pm.AutoAddPolicy) | ||
| client.connect('%s', port=%s, username='testuser', pkey=ECDSAKey.generate(), password='doesntmatter', allow_agent=False, look_for_keys=False) |
There was a problem hiding this comment.
This tests a client that does both public key and password auth.
| } | ||
| s := &server{ | ||
| logf: logger.Discard, | ||
| logf: log.Printf, |
There was a problem hiding this comment.
Changed in order to see SSH logs during test.
There was a problem hiding this comment.
log to the testing.T instead then. stderr logs are super spammy and intermingled and hard to read.
ssh/tailssh/tailssh_test.go
Outdated
| if tc.authErr && !publicKeyUsed.Load() { | ||
| t.Error("public key should have been attempted") | ||
| } | ||
| if (tc.authErr || tc.usesPassword) && !passwordUsed.Load() { |
There was a problem hiding this comment.
Funnily enough, the old test never checked that the password callback was actually used. This now checks for that.
| RUN if echo "$BASE" | grep "alpine:"; then apk add openssh python3 py3-pip; fi | ||
|
|
||
| RUN echo "Install paramiko" | ||
| RUN if echo "$BASE" | grep "ubuntu:"; then pip3 install paramiko==3.5.1; fi |
There was a problem hiding this comment.
Paramiko version is now pinned.
97b7752 to
c59da3d
Compare
tempfork/sshtest/ssh/client_auth.go
Outdated
| if config.SkipNoneAuth && !skippedNoneAuth { | ||
| skippedNoneAuth = true | ||
| continue | ||
| } |
There was a problem hiding this comment.
IIUC, if you simply continue here, the loop will enter again and use the same initial auth value of none.
I think you need it to reach the findNext block below to update the auth method.
There was a problem hiding this comment.
Yes thanks, I've changed this quite a bit. I'm now initializing auth based on whether SkipNoneAuth is configured.
ssh/tailssh/tailssh_test.go
Outdated
| testssh.PasswordCallback(func() (secret string, err error) { | ||
| // This 2nd password callback simulates someone entering a password a | ||
| // 2nd time. | ||
| passwordUsed.Store(true) |
There was a problem hiding this comment.
optional: you can make this into atomic.Uint32 to make sure both passwords were attempted
There was a problem hiding this comment.
It turns out that the Go SSH client only attempts the first method of each type anyway, so I took this out.
6e589ae to
a5f5260
Compare
|
|
||
| return perms, nil | ||
| }, | ||
| PasswordCallback: func(cm gossh.ConnMetadata, pword []byte) (*gossh.Permissions, error) { |
There was a problem hiding this comment.
But by even having this defined, doesn't that make us advertise it as a valid thing to try next if "none" fails? So then an SSH client can ask for or pop open a password prompt, thinking it might work since we advertised "password" as a next authentication types after the "none" failed?
There was a problem hiding this comment.
Yes, having this here means that we respond to the none auth method with both publickey and password as acceptable methods.
There was a problem hiding this comment.
In the interest of minimizing changes, I think we should take this out for now to focus just on achieving parity with 1.80, then we can revisit this question later.
There was a problem hiding this comment.
Ugh. I miss our NextAuthMethodCallback. :(
There was a problem hiding this comment.
Oh! We can use PartialSuccessError I think!
There was a problem hiding this comment.
That seems to have done the trick!
11a880d to
8060d34
Compare
ssh/tailssh/tailssh.go
Outdated
| sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING") | ||
| sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY") | ||
|
|
||
| // terminalError is an empty PartialSuccessError, which results in the ssh |
There was a problem hiding this comment.
... is an empty PartialSuccessError (with no 'Next' authentication methods that may proceed), which results ...
and "SSH server" (since it's in prose)
... "immediately disconnecting the client without sending the 'Partial' bit on the wire"
ssh/tailssh/tailssh.go
Outdated
| // message:error. The contents of err is not leaked to clients in the banner. | ||
| func (c *conn) bannerError(message string, err error) error { | ||
| if err != nil { | ||
| // bannerError writes the given message to an auth banner and then returns an |
ssh/tailssh/tailssh.go
Outdated
| // policy. It writes the message to an auth banner and then returns an empty | ||
| // gossh.PartialSuccessError in order to stop processing authentication | ||
| // attempts and immediately disconnect the client. | ||
| func (c *conn) errDenied(message string) *gossh.PartialSuccessError { |
There was a problem hiding this comment.
kinda weird that this is "errFoo" but below you have methods that are "fooError"
Also weird that you're returning a concrete error type .... this is the classic mistake that's documented at https://go.dev/doc/faq#nil_error but I guess you're always returning a non-nil error at least, so probably okay.
There was a problem hiding this comment.
Yeah, I had made a deliberate choice to return the concrete error type to ensure that no one accidentally changes this to return a different error type, because that would break the invariant that this error should result in authentication terminating. But since I added a check in the defer() in clientAuth(), I don't need this anymore.
ssh/tailssh/tailssh.go
Outdated
| // being tried. | ||
| func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, finalErr error) { | ||
| defer func() { | ||
| if finalErr != nil { |
ssh/tailssh/tailssh.go
Outdated
| pse.Next.PublicKeyCallback != nil { | ||
| panic("clientAuth attempted to return a non-empty PartialSuccessError") | ||
| } | ||
| } else { |
ssh/tailssh/tailssh.go
Outdated
| // terminalError sends an empty gossh.PartialSuccessError to tell gossh.Server | ||
| // to stop processing authentication attempts and instead disconnect | ||
| // immediately. | ||
| func (c *conn) terminalError(err error) *gossh.PartialSuccessError { |
There was a problem hiding this comment.
when does one use terminalError vs errDenied?
There was a problem hiding this comment.
As the doc comment for errDenied states, errDenied is returned by auth callbacks when a connection is denied by the policy. I'll rename terminalError to errUnexpected and document that it's used when encountering unexpected conditions (like being unable to send an auth banner at all).
There was a problem hiding this comment.
Comment on errUnexpected updated.
ssh/tailssh/tailssh_test.go
Outdated
| SkipNoneAuth: skipNoneAuth, | ||
| Auth: []testssh.AuthMethod{ | ||
| testssh.PublicKeysCallback(func() (signers []testssh.Signer, err error) { |
There was a problem hiding this comment.
you're setting SkipNoneAuth on half the tests, but how do you control whether it starts with a password vs starts with a public key if you're setting both?
There was a problem hiding this comment.
I wasn't worrying about the order because when authentication fails, I expect both callbacks to be tried. Being explicitly would be better though.
There was a problem hiding this comment.
Done, the tests now have three passes:
- with
noneauth - without
noneauth starting withpublickey - without
noneauth starting withpassword
88dfdf9 to
515cef4
Compare
| // Connections in the auth phase are handled in handleConnPostSSHAuth. | ||
| // Existing sessions are terminated by Shutdown. | ||
| return nil, errDenied("tailscale: server is shutting down") | ||
| return nil, errors.New("server is shutting down") |
There was a problem hiding this comment.
Drive-by: there was no point to returning errDenied here since we don't even have an SSH connection at this point.
ssh/tailssh/tailssh.go
Outdated
| // terminalError is an empty gossh.PartialSuccessError (with no 'Next' | ||
| // authentication methods that may proceed), which results in the SSH | ||
| // server immediately disconnecting the client. | ||
| terminalError = &gossh.PartialSuccessError{} |
There was a problem hiding this comment.
error variables are named errFoo (or ErrFoo) in Go, and types are named fooError (or FooError)
So this would be errTerminal or errNoMoreAuthTypes if you want to be more explicit.
ssh/tailssh/tailssh.go
Outdated
| // If access is denied, it returns an error. This must always be an empty | ||
| // gossh.PartialSuccessError to prevent further authentication methods from | ||
| // being tried. | ||
| func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, finalErr error) { |
There was a problem hiding this comment.
instead of "final", I think we normally use "retErr" (short for returned error). grep shows that retErr is pretty common
| @@ -410,6 +410,48 @@ func TestSSHAgentForwarding(t *testing.T) { | |||
| } | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
btw, remove the ancient // +build integrationtest from the top of this file. It's the only such old one in the tree.
ssh/tailssh/tailssh_test.go
Outdated
| // When skipping `none` auth, the first callback should always | ||
| // fire, and the 2nd callback should fire only if | ||
| // authentication failed. | ||
| expectPublicKey := false |
There was a problem hiding this comment.
Go generally uses word "want" instead of "expect" in tests
515cef4 to
1935ab4
Compare
Some clients don't request 'none' authentication. Instead, they immediately supply a password or public key. This change allows them to do so, but ignores the supplied credentials and authenticates using Tailscale instead. Updates #14922 Signed-off-by: Percy Wegmann <percy@tailscale.com>
1935ab4 to
1a09996
Compare
Some clients don't request 'none' authentication. Instead, they immediately supply
a password or public key. This change allows them to do so, but ignores the supplied
credentials and authenticates using Tailscale instead.
Updates #14922
This applies #14967 to
main. It's a substantially different change becausemainno longer uses the forkedgolang-x-crypto.