Skip to content

ssh/tailssh: accept passwords and public keys#14970

Merged
oxtoacart merged 1 commit intomainfrom
percy/issue14922-main
Feb 13, 2025
Merged

ssh/tailssh: accept passwords and public keys#14970
oxtoacart merged 1 commit intomainfrom
percy/issue14922-main

Conversation

@oxtoacart
Copy link
Copy Markdown
Contributor

@oxtoacart oxtoacart commented Feb 10, 2025

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 because main no longer uses the forked golang-x-crypto.

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch 2 times, most recently from c4fa9d9 to d54f95c Compare February 10, 2025 19:14
},
NoClientAuth: true, // required for the NoClientAuthCallback to run
NoClientAuthCallback: func(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
c.logf("ZZZZ NoClientAuthCallback")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

delete

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

and below

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch from d54f95c to 7b22f56 Compare February 10, 2025 20:59
@oxtoacart oxtoacart changed the title ssh/tailssh: accept passwords and public keys ssh/tailssh: accept public keys Feb 10, 2025
@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch 4 times, most recently from 29372a9 to dd7ce90 Compare February 10, 2025 23:51
@oxtoacart oxtoacart requested a review from awly February 10, 2025 23:52
}

if !c.clientAuthAttempted.CompareAndSwap(false, true) {
panic("clientAuth unexpectedly called twice")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can't they call NoClientAuthCallback and then later password or PublicKeyCallback?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

oh, and if they hit the +password hack, we swap the password callback for this one:

if strings.HasSuffix(cm.User(), forcePasswordSuffix) {
return nil, &gossh.PartialSuccessError{
Next: gossh.ServerAuthCallbacks{
PasswordCallback: func(_ gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) {
return &gossh.Permissions{}, nil
},
},
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch from dd7ce90 to 3288ffb Compare February 11, 2025 14:46
@oxtoacart oxtoacart changed the title ssh/tailssh: accept public keys ssh/tailssh: accept passwords and public keys Feb 11, 2025
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)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This tests a client that does both public key and password auth.

}
s := &server{
logf: logger.Discard,
logf: log.Printf,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed in order to see SSH logs during test.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

log to the testing.T instead then. stderr logs are super spammy and intermingled and hard to read.

if tc.authErr && !publicKeyUsed.Load() {
t.Error("public key should have been attempted")
}
if (tc.authErr || tc.usesPassword) && !passwordUsed.Load() {
Copy link
Copy Markdown
Contributor Author

@oxtoacart oxtoacart Feb 11, 2025

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Paramiko version is now pinned.

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch 7 times, most recently from 97b7752 to c59da3d Compare February 11, 2025 16:18
Comment on lines +73 to +78
if config.SkipNoneAuth && !skippedNoneAuth {
skippedNoneAuth = true
continue
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

@oxtoacart oxtoacart Feb 11, 2025

Choose a reason for hiding this comment

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

Yes thanks, I've changed this quite a bit. I'm now initializing auth based on whether SkipNoneAuth is configured.

testssh.PasswordCallback(func() (secret string, err error) {
// This 2nd password callback simulates someone entering a password a
// 2nd time.
passwordUsed.Store(true)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

optional: you can make this into atomic.Uint32 to make sure both passwords were attempted

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It turns out that the Go SSH client only attempts the first method of each type anyway, so I took this out.

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch 4 times, most recently from 6e589ae to a5f5260 Compare February 11, 2025 17:03

return perms, nil
},
PasswordCallback: func(cm gossh.ConnMetadata, pword []byte) (*gossh.Permissions, error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, having this here means that we respond to the none auth method with both publickey and password as acceptable methods.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ugh. I miss our NextAuthMethodCallback. :(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh! We can use PartialSuccessError I think!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That seems to have done the trick!

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch 2 times, most recently from 11a880d to 8060d34 Compare February 11, 2025 21:21
sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING")
sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY")

// terminalError is an empty PartialSuccessError, which results in the ssh
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

... 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"

// 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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

accidental double space

// 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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

// being tried.
func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, finalErr error) {
defer func() {
if finalErr != nil {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

delete and unindent this?

pse.Next.PublicKeyCallback != nil {
panic("clientAuth attempted to return a non-empty PartialSuccessError")
}
} else {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

} else if finalErr != nil {

// 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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

when does one use terminalError vs errDenied?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment on errUnexpected updated.

Comment on lines +910 to +912
SkipNoneAuth: skipNoneAuth,
Auth: []testssh.AuthMethod{
testssh.PublicKeysCallback(func() (signers []testssh.Signer, err error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I wasn't worrying about the order because when authentication fails, I expect both callbacks to be tried. Being explicitly would be better though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, the tests now have three passes:

  • with none auth
  • without none auth starting with publickey
  • without none auth starting with password

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch 2 times, most recently from 88dfdf9 to 515cef4 Compare February 12, 2025 13:19
// 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")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Drive-by: there was no point to returning errDenied here since we don't even have an SSH connection at this point.

// 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{}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

// 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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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) {
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

btw, remove the ancient // +build integrationtest from the top of this file. It's the only such old one in the tree.

// When skipping `none` auth, the first callback should always
// fire, and the 2nd callback should fire only if
// authentication failed.
expectPublicKey := false
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Go generally uses word "want" instead of "expect" in tests

@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch from 515cef4 to 1935ab4 Compare February 12, 2025 20:51
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>
@oxtoacart oxtoacart force-pushed the percy/issue14922-main branch from 1935ab4 to 1a09996 Compare February 12, 2025 22:45
@oxtoacart oxtoacart merged commit db23110 into main Feb 13, 2025
@oxtoacart oxtoacart deleted the percy/issue14922-main branch February 13, 2025 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants