1

I plan to write a PHP script that makes an SSH connection. I've investigated how to do this and this looks the most promising solution: https://github.com/phpseclib/phpseclib My only issue is how to handle the fact that my SSH key has a passphrase, and I don't want to have to enter it every time I run the script. For every day SSH use I have ssh-agent running in the background, and it's configured to use pinentry. This makes it so that I don't have to enter my passphrase EVERY time. Any ideas as to how I could get PHP and ssh-agent to talk to each other? My only clue is that ssh-agent sets an environment variable, SSH_AUTH_SOCK, pointing to a socket file.

While the documentation for phpseclib addresses this issue, its answer is silly (just put the passphrase in the code): http://phpseclib.sourceforge.net/ssh/2.0/auth.html#encrsakey

UPDATE: I've looked more into phpseclib and written my own simple wrapper class. However, I cannot get it to login either through ssh-agent or by supplying my RSA key. Only password-based authentication works, contrary to my experiences logging in directly with the ssh command. Here is my code:

<?php
// src/Connection.php
declare(strict_types=1);
namespace MyNamespace\PhpSsh;

use phpseclib\System\SSH\Agent;
use phpseclib\Net\SSH2;
use phpseclib\Crypt\RSA;
use Exception;

class Connection
{
    private SSH2 $client;
    private string $host;
    private int $port;
    private string $username;

    /**
     * @param string $host
     * @param int $port
     * @param string $username
     * 
     * @return void
     */
    public function __construct(string $host, int $port,
        string $username)
    {
        $this->host = $host;
        $this->port = $port;
        $this->username = $username;
        $this->client = new SSH2($host, $port);
    }

    /**
     * @return bool
     */
    public function connectUsingAgent(): bool
    {
        $agent = new Agent();
        $agent->startSSHForwarding($this->client);
        return $this->client->login($this->username, $agent);
    }

    /**
     * @param string $key_path
     * @param string $passphrase
     * 
     * @return bool
     * @throws Exception
     */
    public function connectUsingKey(string $key_path, string $passphrase = ''): bool
    {
        if (!file_exists($key_path)) {
            throw new Exception(sprintf('Key file does not exist: %1$s', $key_path));
        }

        if (is_dir($key_path)) {
            throw new Exception(sprintf('Key path is a directory: %1$s', $key_path));
        }

        if (!is_readable($key_path)) {
            throw new Exception(sprintf('Key file is not readable: %1$s', $key_path));
        }

        $key = new RSA();

        if ($passphrase) {
            $key->setPassword($passphrase);
        }

        $key->loadKey(file_get_contents($key_path));

        return $this->client->login($this->username, $key);
    }

    /**
     * @param string $password
     * 
     * @return bool
     */
    public function connectUsingPassword(string $password): bool
    {
        return $this->client->login($this->username, $password);
    }

    /**
     * @return void
     */
    public function disconnect(): void
    {
        $this->client->disconnect();
    }

    /**
     * @param string $command
     * @param callable $callback
     * 
     * @return string|false
     */
    public function exec(string $command, callable $callback = null)
    {
        return $this->client->exec($command, $callback);
    }

    /**
     * @return string[]
     */
    public function getErrors(): array {
        return $this->client->getErrors();
    }
}

And:

<?php
    // test.php
    use MyNamespace\PhpSsh\Connection;

    require_once(__DIR__ . '/vendor/autoload.php');

    (function() {
        $host = '0.0.0.0'; // Fake, obviously
        $username = 'user'; // Fake, obviously
        $connection = new Connection($host, 22, $username);
        $connection_method = 'AGENT'; // or 'KEY', or 'PASSWORD'

        switch($connection_method) {
            case 'AGENT':
                $connected = $connection->connectUsingAgent();
                break;
            case 'KEY':
                $key_path = getenv( 'HOME' ) . '/.ssh/id_rsa.pub';
                $passphrase = trim(fgets(STDIN)); // Pass this in on command line via < key_passphrase.txt
                $connected = $connection->connectUsingKey($key_path, $passphrase);
                break;
            case 'PASSWORD':
            default:
                $password = trim(fgets(STDIN)); // Pass this in on command line via < password.txt
                $connected = $connection->connectUsingPassword($password);
                break;
        }

        if (!$connected) {
            fwrite(STDERR, "Failed to connect to server!" . PHP_EOL);
            $errors = implode(PHP_EOL, $connection->getErrors());
            fwrite(STDERR, $errors . PHP_EOL);
            exit(1);
        }

        $command = 'whoami';
        $result = $connection->exec($command);
        echo sprintf('Output of command "%1$s:"', $command) . PHP_EOL;
        echo $result . PHP_EOL;

        $command = 'pwd';
        $result = $connection->exec($command);
        echo sprintf('Output of command "%1$s:"', $command) . PHP_EOL;
        echo $result . PHP_EOL;

        $connection->disconnect();
    })();

The SSH2 class has a getErrors() method, unfortunately it wasn't logging any in my case. I had to debug the class. I found that, whether using ssh-agent or passing in my key, it always reached this spot (https://github.com/phpseclib/phpseclib/blob/2.0.23/phpseclib/Net/SSH2.php#L2624):

<?php
// vendor/phpseclib/phpseclib/phpseclib/Net/SSH2.php: line 2624
extract(unpack('Ctype', $this->_string_shift($response, 1)));

switch ($type) {
    case NET_SSH2_MSG_USERAUTH_FAILURE:
        // either the login is bad or the server employs multi-factor authentication
        return false;
    case NET_SSH2_MSG_USERAUTH_SUCCESS:
        $this->bitmap |= self::MASK_LOGIN;
        return true;
}

Obviously the response being returned is of type NET_SSH2_MSG_USERAUTH_FAILURE. There's nothing wrong with the login, of that I'm sure, so per the comment in the code that means the host (Digital Ocean) must use multi-factor authentication. Here's where I'm stumped. What other means of authentication am I lacking? This is where my understanding of SSH fails me.

William Beaumont
  • 357
  • 2
  • 6
  • 15
  • "_its answer is silly (just put the passphrase in the code)_". To be fair, that's the same "answer" used by OpenSSL and libssh2. See http://php.net/openssl-pkey-get-private and https://www.php.net/ssh2-auth-pubkey-file – neubert Feb 11 '20 at 12:49
  • I see you edited your question since my response. I didn't know you had done so, lest I might have replied sooner. Anyway, I updated my answer in response to your update to your question! – neubert Feb 22 '20 at 21:23

2 Answers2

1

phpseclib supports SSH Agent. eg.

use phpseclib\Net\SSH2;
use phpseclib\System\SSH\Agent;

$agent = new Agent;

$ssh = new SSH2('localhost');
if (!$ssh->login('username', $agent)) {
    throw new \Exception('Login failed');
}

Updating with your latest edits

UPDATE: I've looked more into phpseclib and written my own simple wrapper class. However, I cannot get it to login either through ssh-agent or by supplying my RSA key. Only password-based authentication works, contrary to my experiences logging in directly with the ssh command.

What do your SSH logs look like with both Agent authentication and direct public key authentication? You can get the SSH logs by doing define('NET_SSH2_LOGGING', 2) at the top and then echo $ssh->getLog() after the authentication has failed.

neubert
  • 15,947
  • 24
  • 120
  • 212
  • Thanks, I'll give that a try. – William Beaumont Feb 12 '20 at 20:21
  • I did what you suggested, added `NET_SSH2_LOGGING` and called `->getLog()`, but it's mostly a bunch of hex data, and then at the end: `<- NET_SSH2_MSG_USERAUTH_FAILURE (since last: 0.0914, network: 0.091s) 00000000 00:00:00:00:00:00:00:00:00:00:00:00:00:00 ....publickey.` (It's not actually all 0s, I changed it because I wasn't sure if it was outputting something I shouldn't be sharing.) – William Beaumont Feb 24 '20 at 00:09
  • @WilliamBeaumont - I'd say post the full logs. As for your concern that sensitive info is being shared... it isn't. In the case of public key crypto the only sensitive bit of data that exists is the private key and SSH doesn't send that it over. It sends the public key over and later a signature of the so-called exchange hash. The private key is never sent over. And in the case of passwords... it replaces whatever you typed in as the password with "password". See https://github.com/phpseclib/phpseclib/blob/2.0.24/phpseclib/Net/SSH2.php#L2283 – neubert Feb 24 '20 at 01:24
  • SSH with agent: https://pastebin.com/5rYDsk1K SSH with key: https://pastebin.com/LZDsB82z – William Beaumont Feb 24 '20 at 04:15
  • 1
    @WilliamBeaumont - what version of phpseclib are you using? In your logs I note that rsa-sha2-256 is being used. 2.0.13 added support for authentication using that method and 2.0.14 fixed some issues with that support. In 2.0.22 `setPreferredAlgorithms()` was added to better manage that. Maybe try `$ssh->setPreferredAlgorithms(['hostkey' => ['ssh-rsa']]);` if you're using >= 2.0.22. – neubert Feb 25 '20 at 01:49
  • I did that and agent authentication finally worked! Still a problem for key authentication, but I don't care about that as much. Thanks! – William Beaumont Feb 26 '20 at 21:31
1

Per neubert, what I had to do was add this line to Connection.php and I was able to get agent-based authentication to work:

$this->client->setPreferredAlgorithms(['hostkey' => ['ssh-rsa']]);

I still can't get key-based authentication to work, but I don't care about that as much.

William Beaumont
  • 357
  • 2
  • 6
  • 15