Validating SSH keys on Laravel
Tags: validation, php, laravel, pest
Published: 2023-09-12 20:45:41

When we're called to develop an application, we should keep in mind that we might have to deal with various types of problems, some of which we may never have imagined facing. However, sometimes we need to step out of our comfort zone.

## Understanding the Problem

A few days ago, I was tasked with building a feature that would receive a developer's public key and later send it to Laravel forge, allowing the user to have SSH access to the respective servers.

> Awesome, Matheusão, how am I going to validate this type of data?

Initially, we think about validating the basics, such as the length of the string, whether it already exists in the database, etc:

```php
'ssh_key' => ['nullable', 'string', 'unique:users,ssh_key', 'max:5000']
```

Okay, but what if the user passes, I don't know, all the letters of the alphabet? Unfortunately, it will pass the validation 🙁.

## In Search of the Perfect Validation

I did a lot of research on how to perform this validation. In many blogs, I saw many people recommending using native functions like **`openssl_verify`**, **`openssl_get_publickey`**, or **`openssl_pkey_get_details`**, but unfortunately, they didn't work for what I needed (Remember, an SSH key is different from an SSL key, so these functions won't work). In other forums, I saw people suggesting using the package [https://phpseclib.com/](https://phpseclib.com/). But think about it, why install a package when you're only going to use one class and one of its methods?

I see this as completely unnecessary coupling, but anyway...

## Going a Bit Deeper

After some research, I found that we can use `ssh-keygen` to validate this string for us, but how?

For this, we can use two flags, `-l` to get the fingerprint and `-f` to specify the file path. So our command would look like this:

```bash
ssh-keygen -lf /path/to/my/file.pub
```

And this way, we can check if our SSH key is valid or not.

## Creating Our Validation Command

Laravel introduced a component called [Process](https://laravel.com/docs/10.x/processes) starting from version 10, which is nothing more than a wrapper around Symfony's [Process](https://symfony.com/doc/current/components/process.html) component. It's with this little guy that we're going to work our magic.

> Of course, we could use the `exec` function, a native PHP function. However, if you think you don't need to use this wrapper, feel free to do so 🙂👍🏻.

Let's think about what we need to do:

- We need to receive the string containing the user's key.
- We need to save this string somewhere accessible.
- We need to call the `ssh-keygen` command with the file path.
- We need to delete the file after validation.

## Setting Things Up

Let's create a directory inside `storage/app` called ssh. Don't forget to exclude this new directory from your version control:

_storage/app/.gitignore_
```
*
!public/
!.gitignore
!ssh/
```

_storage/app/ssh/.gitignore_
```
*
!.gitignore
```

## Writing Our Class
Now we can create our class that will interact with `ssh-keygen`.

**_App/Terminal/ValidateSsh.php_**

```php
<?php

declare(strict_types=1);

namespace App\Terminal;

use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;

class ValidateSsh
{
    private string $keyPath;

    public function __construct(
        private readonly string $content
    ) {
        $this->keyPath = storage_path('app/ssh/' . Str::uuid() . '.pub');

        file_put_contents($this->keyPath, $this->content);
    }

    public function __invoke(): bool
    {
        return Process::run(
            command: 'ssh-keygen -lf ' . $this->keyPath . ' && rm ' . $this->keyPath,
        )->successful();
    }
}
```

Great, our class is ready to be used.

* It receives the content and saves it with a random name.
* It checks the file, and if successful, it deletes it as well.

Now, let's write our tests.

**_tests/Unit/Terminal/ValidateSshTest.php_**

```php
<?php

declare(strict_types=1);

use App\Terminal\ValidateSsh;

it('should return true if process if file is valid', function (string $key) {
    $validateSsh = new ValidateSsh($key);

    expect($validateSsh())->toBeTruthy();
})->with([
    'RSA'   => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD3vYKSuh7rJf+NtWn04CFyT9+nmx+i+/sP+yMN9ueJ+Rd5Ku6d9kgscK2xwlRlkcA0sethslu0WUsG81RC1lVpF6iLrc/9O45ZhEY1CB/7dofr+7ZNwu/DJtbW6YE7oyT5G97BUW763TMq/YO9/xjMToetElTEJ4hUVWdP8q93b3MVHBazk2PEuS05wzP4p5XeQnhKq4LISetJFEgI8Y+HEpK29GiU/18fhaGZvdVwOToOxTwEwBbS3fTLNkBaUTWw9q3i7S60RRncBCHppcs2irrzw7yt7ZQOnut/BIjIGESoxx+N4ZrpTmX6P5d3/9Duk40Mfwh1ftsvze6o5AW4Xi0tki8b6bsMXmO7SapqVdiMZ5/4BWOkqHWhi926qz7I9NWoZuVFAUpSoe6fObzQBRooVp7ARw7gJ4C+Q4xc1gJJkZoQ/Wj/wHkVnbLw9M5+t5GjyWgDDOr5iyoGOyIwhuEFvATzIYH0z5B6anL1n6XQmeGh5OWKJN8wE5qVNTU= worker@envoyer.io',
    'EDCSA' => 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFvXWSVYzRnjxYsz/xKjOjAaPjzg98MMHaDulQYczTX28xlsMmFkviCeCCv7CLh19ydoH4LNKpvgTGiMXz8ib68= worker@envoyer.',
]);

it('should return false if ssh file is invalid', function () {
    $validateSsh = new ValidateSsh('a simple text file');

    expect($validateSsh())->toBeFalsy();
});
```

## Writing Our Rule

Think it's over? Not at all. The responsibility of the `ValidateSsh` class is only to check if the key is valid or not.

Let's create a rule so that we can use this validation.

```bash
php artisan make:rule IsSshKeyValid
```

Great, now we can do the following:

```php
<?php

declare(strict_types=1);

namespace App\Rules;

use App\Terminal\ValidateSsh;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;

class IsSshKeyValid implements ValidationRule
{
    /**
     * @param Closure(string): PotentiallyTranslatedString $fail
     */
    public function validate(
        string $attribute,
        mixed $value,
        Closure $fail
    ): void {
        $validateSsh = new ValidateSsh($value);

        if (!$validateSsh()) {
            $fail('The :attribute is not a valid SSH key.');
        }
    }
}
```

With this, we're ready to write our HTTP tests ❤️

## Testing Our HTTP Call

Before moving on to the HTTP tests, we need to add our rule to our validation rules:

```php
'ssh_key' => [
    'nullable',
    'string',
    'unique:users,ssh_key',
    'max:5000',
    new IsSshKeyValid(),
],
```

And our tests for this field can look like this:

```php
it('should validate `ssh_key` field', function (mixed $value, string $error) {
    login();

    postJson(route('api.users.store'), ['ssh_key' => $value])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['ssh_key' => $error]);
})->with([
    fn () => [5000, __('validation.string', ['attribute' => 'ssh key'])],
    fn () => [str_repeat('a', 5001), __('validation.max.string', ['attribute' => 'ssh key', 'max' => 5000])],
    fn () => ['aa', 'The ssh key is not a valid SSH key.'],
    function () {
        $user = User::factory()
            ->create([
                'ssh_key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD3vYKSuh7rJf+NtWn04CFyT9+nmx+i+/sP+yMN9ueJ+Rd5Ku6d9kgscK2xwlRlkcA0sethslu0WUsG81RC1lVpF6iLrc/9O45ZhEY1CB/7dofr+7ZNwu/DJtbW6YE7oyT5G97BUW763TMq/YO9/xjMToetElTEJ4hUVWdP8q93b3MVHBazk2PEuS05wzP4p5XeQnhKq4LISetJFEgI8Y+HEpK29GiU/18fhaGZvdVwOToOxTwEwBbS3fTLNkBaUTWw9q3i7S60RRncBCHppcs2irrzw7yt7ZQOnut/BIjIGESoxx+N4ZrpTmX6P5d3/9Duk40Mfwh1ftsvze6o5AW4Xi0tki8b6bsMXmO7SapqVdiMZ5/4BWOkqHWhi926qz7I9NWoZuVFAUpSoe6fObzQBRooVp7ARw7gJ4C+Q4xc1gJJkZoQ/Wj/wHkVnbLw9M5+t5GjyWgDDOr5iyoGOyIwhuEFvATzIYH0z5B6anL1n6XQmeGh5OWKJN8wE5qVNTU= worker@envoyer.io',
            ]);

        return [$user->ssh_key, __('validation.unique', ['attribute' => 'ssh key'])];
    },
]);

it('should store an user', function () {
    login();

    $data = [
        'name'    => 'Matheus Santos',
        'email'   => 'matheusao@my-company.com',
        'ssh_key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD3vYKSuh7rJf+NtWn04CFyT9+nmx+i+/sP+yMN9ueJ+Rd5Ku6d9kgscK2xwlRlkcA0sethslu0WUsG81RC1lVpF6iLrc/9O45ZhEY1CB/7dofr+7ZNwu/DJtbW6YE7oyT5G97BUW763TMq/YO9/xjMToetElTEJ4hUVWdP8q93b3MVHBazk2PEuS05wzP4p5XeQnhKq4LISetJFEgI8Y+HEpK29GiU/18fhaGZvdVwOToOxTwEwBbS3fTLNkBaUTWw9q3i7S60RRncBCHppcs2irrzw7yt7ZQOnut/BIjIGESoxx+N4ZrpTmX6P5d3/9Duk40Mfwh1ftsvze6o5AW4Xi0tki8b6bsMXmO7SapqVdiMZ5/4BWOkqHWhi926qz7I9NWoZuVFAUpSoe6fObzQBRooVp7ARw7gJ4C+Q4xc1gJJkZoQ/Wj/wHkVnbLw9M5+t5GjyWgDDOr5iyoGOyIwhuEFvATzIYH0z5B6anL1n6XQmeGh5OWKJN8wE5qVNTU= worker@envoyer.io',
    ];

    postJson(route('api.users.store'), $data)
        ->assertCreated();

    assertDatabaseHas(Users::class, $data);
});
```

Cool, right?

Now, I can register users in my system without worrying about those funny folks who might enter "aaaaaaa" in the `ssh_key` field 😃.

And remember, sometimes we need to think outside the box to find solutions to some problems. The more open-minded we are, the faster we can make progress and learn new things.

Cheers, and until next time 😗 🧀