Validating SSH keys on Laravel
Matheus Lopes Santos
Posted on September 12, 2023
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']
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
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/
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
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
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.
php artisan make:rule IsSshKeyValid
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.');
}
}
}
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(),
],
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);
});
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 π π§
Posted on September 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.