ssh2 icon indicating copy to clipboard operation
ssh2 copied to clipboard

Request: Delay client password entry until password actually needed

Open chunky opened this issue 1 year ago • 8 comments

[If this is already possible, I have been unable to figure out how]

When using the SSH Client, if the server responds with authentication methods that include "password", but not "keyboard-interactive", and the methods on the list before it have failed, I would like there to be a way to ask for a password then.

For example, connecting the client to my local fedora server by default has these types, according to ssh -v localhost:

debug1: Authentications that can continue: publickey,gssapi-keyex,gssapi-with-mic,password

"Password" is the least-preferred option, but as far as I can tell the only way to inject it is when instantiating the connection, with a "password" parameter added to the config. This forces a workflow where the client application must ask for the password before attempting the connection, if a password will end up being needed. Except users probably don't want that, if any of the previous methods will work, which is unknown until the connection is made.

I have tried setting authHandler [in that connection] to a function, but for this SSH server, the first time it is called, "methodsleft" [the first parameter to the authHandler function] is none.

I am able to do what I want, if the server requests keyboard-interactive, by adding an event handler for "keyboard-interactive" to the connection object. Unfortunately, none of the servers I've tried connecting to [varying ubuntu, RHEL, and Fedora systems] have had "keyboard-interactive" as an option, only "password".

So my recommendation would be for an additional event type, "password", so that this would work:

conn = new Client();
conn.on("keyboard-interactive", (stuff, finish) => {get input from user based on stuff; finish([pass])})
         .on("password", (finish) => { get input from user; finish(pass);} // This would be new

Thank you Gary

chunky avatar Jul 05 '24 05:07 chunky

Use authHandler and attempt the 'none' method first. On the second time around methodsLeft should be non-null if 'none' didn't succeed. At that point you can prompt the user for whichever authentication method and pass the relevant information to the callback.

Be aware that while most server implementations are honest about which authentication methods are available for clients to try, it's not required by the protocol.

mscdex avatar Jul 05 '24 08:07 mscdex

I should also add that in general the list of authentication methods the server responds with is not in any particular order (preferred or otherwise).

mscdex avatar Jul 05 '24 08:07 mscdex

Ah, thank you. So after the none method, I was able to capture the next methods and do password.

Now I'm back to my original thing: How do I get it to default to whatever-it-was-going-to-do in other cases? For example, I'm already passing an "agent" parameter to the connection config - in cases where agent is useful, I want it to just do that. Ditto in cases where I pass the private key. And I'm not sure how this would play with the keyboard-interactive callback. Here's where my code currently stands:

const cfg: ConnectConfig = {
    host: this._config.host,
    port: this._config.port,
    username: this._config.username,
    readyTimeout: myTimeout,
    agent: process.env.SSH_AUTH_SOCK || undefined,
    tryKeyboard: true, // Let library know that passwords are on offer
    password: "", // Setting this to not-undefined, "password" is added to the list of authsAllowed in the client
    authHandler: (methodsLeft, partialSuccess, cb) => {
       if (methodsLeft === null) {
         cb({
           type: "none",
           username: this._config.username,
         });
       } else if (methodsLeft[0] === "password") {
         cb({
           type: "password",
           username: this._config.username,
           password: "password",
         });
       } else {
         // Right here should just fall back to whatever the client does normally with those other methods
       }
    },
};

conn = new Client();
conn.on("keyboard-interactive", (params) => {thing that returns password, if keyboard-interactive needed password));
conn.connect(cfg);

Mainly the point of me opening this ticket was to see if "password" can do something in particular I want, but without changing the behaviour of any of the rest of what it does.

chunky avatar Jul 06 '24 15:07 chunky

If you're supplying an authHandler the best you can do is pass just the string authentication method name to the callback.

mscdex avatar Jul 06 '24 18:07 mscdex

I tried that, and there are a couple additional things that are nonobvious:

  • After each time around, the contents of "methodsLeft" isn't getting any shorter [as it usually does in "ssh -v" output]. So just trying to do something based on methodsLeft[0] is an infinite loop.
    • Should the authHandler be popping items off that list?
  • There's no obvious way to identify if I should move to the next item in the queue; the callback doesn't return anything. So I could just skip through all the options in a loop, but there's no way to know when the authentication was successful and therefore I should stop looping.

You are right that the SSH server authentication method list isn't ordered by preference. Intuitively, clients should preferentially try non-interactive methods first; it's a user-unfriendly experience to ask for a password, if they have public-key(/agent) authentication configured. At the moment, the documentation for authHandler says that password authentication will be tried earlier than public key - so if passwords would be enabled in the client, then they would always be asked for, which is unfriendly to users.

Current code:

      const cfg: ConnectConfig = {
        host: this._config.host,
        port: this._config.port,
        username: this._config.username,
        readyTimeout: sasLaunchTimeout,
        agent: process.env.SSH_AUTH_SOCK || undefined,
        tryKeyboard: true, // Let library know that passwords are on offer
        password: "", // Setting this to not-undefined, "password" is added to the list of authsAllowed in the client
        authHandler: (methodsLeft, partialSuccess, cb) => {
          console.log(methodsLeft);
          console.log(partialSuccess);
          if (methodsLeft === null) {
            cb({
              type: "none",
              username: this._config.username,
            });
          } else if (methodsLeft[0] === "password") {
            cb({
              type: "password",
              username: this._config.username,
              password: "mypass",
            });
          } else {
            cb(methodsLeft[0]); // Infinite loop down here
          }
        },
      };

If my initial proposed solution doesn't make sense [having an even handler on the connection object for "password", just like "keyboard-interactive"], perhaps it would make sense for ConnectConfig.password to accept a lambda that returns a string, as well as just a string, for the password option? So a solution that does what I'm trying to achieve would look like:

Example:

      const cfg: ConnectConfig = {
        host: this._config.host,
        port: this._config.port,
        username: this._config.username,
        readyTimeout: sasLaunchTimeout,
        agent: process.env.SSH_AUTH_SOCK || undefined,
        tryKeyboard: true,
        password: () => { return promptUserForPassword() },
   };

chunky avatar Jul 06 '24 21:07 chunky

  • After each time around, the contents of "methodsLeft" isn't getting any shorter

The list given is from the server and it can send whatever it wants.

  • Should the authHandler be popping items off that list?

If you are using a custom authentication handler, you will need to maintain your own queue of methods to try.

  • There's no obvious way to identify if I should move to the next item in the queue

Typically you move to the next item in the queue if your authentication handler function has been called, it's only called when authentication hasn't finished.

Be aware that some servers could be set up with more complex authentication workflows that require multiple, specific methods to be used (maybe even a specific order) as indicated by the partialSuccess argument (some 2FA mechanisms may utilize this).

mscdex avatar Jul 06 '24 23:07 mscdex

My key point is that I don't want to have a custom auth handler, or deal with all the complex workflows. I just want a way to delay asking for a password until it's absolutely necessary, and ideally to have the client try all the non-interactive methods first.

All of this is in support of not having to ask the user for the password until it's absolutely necessary, which often will mean "not ask at all", in order to improve the user's experience.

chunky avatar Jul 07 '24 02:07 chunky

Custom authentication handlers exist exactly for situations like this where you need more control over the authentication process. If you want to prompt the user only if the server offers 'password' authentication, then you can absolutely do that with a custom authentication handler. I don't see what the problem is.

Besides, typically users already know how they want to authenticate before the connection is attempted, in which case you would just set whatever appropriate values in the config object and be done with it.

mscdex avatar Jul 07 '24 06:07 mscdex