DEV Community

Cover image for Validating SSH keys on Laravel
Matheus Lopes Santos
Matheus Lopes Santos

Posted on

Validating SSH keys on Laravel

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:

'ssh_key' => ['nullable', 'string', 'unique:users,ssh_key', 'max:5000']
Enter fullscreen mode Exit fullscreen mode

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/. 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:

ssh-keygen -lf /path/to/my/file.pub
Enter fullscreen mode Exit fullscreen mode

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

Creating Our Validation Command

Laravel introduced a component called Process starting from version 10, which is nothing more than a wrapper around Symfony's Process 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/
Enter fullscreen mode Exit fullscreen mode

storage/app/ssh/.gitignore

*
!.gitignore
Enter fullscreen mode Exit fullscreen mode

Writing Our Class

Now we can create our class that will interact with ssh-keygen.

App/Terminal/ValidateSsh.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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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();
});
Enter fullscreen mode Exit fullscreen mode

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.

php artisan make:rule IsSshKeyValid
Enter fullscreen mode Exit fullscreen mode

Great, now we can do the following:

<?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.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

'ssh_key' => [
    'nullable',
    'string',
    'unique:users,ssh_key',
    'max:5000',
    new IsSshKeyValid(),
],
Enter fullscreen mode Exit fullscreen mode

And our tests for this field can look like this:

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);
});
Enter fullscreen mode Exit fullscreen mode

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 😗 🧀

Top comments (3)

Collapse
 
justplayerde profile image
Justin K.

The fact that this validation creates a file on the server's filesystem and also runs a command doing something with said file is a bit dangerous if not done correctly tho, and i recommend using the Storage facade instead of raw file_put_contents as this would be more integrated into laravel to make use of its features.

Otherwise this a good and helpful article ^^

Collapse
 
webwizo profile image
Asif Iqbal

Nice to read such code. I hope I will be able to translate this code in my personal project.

Awesome 👏

Collapse
 
devlopez profile image
Matheus Lopes Santos

Thank you my friend ❤️