feat(ssh-keys): name an added SSH key so it is identifiable

The "add SSH key" modal had no name field — keys appeared only by comment +
fingerprint, so additional keys weren't tellable apart. Add a name field; the name
becomes the key's OpenSSH comment (what the server list shows), for both generated
and pasted keys. Sanitised (control chars stripped, whitespace collapsed, capped at
64 chars) so a crafted name cannot inject a second authorized_keys line — proven by
test, on top of addAuthorizedKey()'s own whitespace-collapse + base64 encoding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 00:53:49 +02:00
parent 7ec2c065bb
commit d56bdadd14
5 changed files with 138 additions and 3 deletions

View File

@ -18,6 +18,8 @@ class AddSshKey extends ModalComponent
{
public int $serverId;
public string $keyName = '';
public string $publicKey = '';
public ?string $generatedPrivate = null;
@ -40,10 +42,34 @@ class AddSshKey extends ModalComponent
{
$this->error = null;
$key = EC::createKey('ed25519');
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => 'clusev-key']);
// The comment is what identifies the key in the server's list — use the operator's name.
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => $this->keyComment() ?: 'clusev-key']);
$this->generatedPrivate = (string) $key->toString('OpenSSH');
}
/** Sanitised key label, safe as an authorized_keys comment (single line, ≤64 chars), or '' if none. */
private function keyComment(): string
{
$name = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $this->keyName) ?? '';
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
return mb_substr($name, 0, 64);
}
/** Set the OpenSSH comment of a public-key line to $comment (only when a name was given). */
private function withComment(string $pub, string $comment): string
{
if ($comment === '') {
return $pub;
}
$parts = preg_split('/\s+/', trim($pub), 3);
if (count($parts) < 2) {
return $pub; // not a recognisable key line — let the SSH layer reject it
}
return $parts[0].' '.$parts[1].' '.$comment;
}
public function save(FleetService $fleet): void
{
$this->error = null;
@ -55,8 +81,11 @@ class AddSshKey extends ModalComponent
return;
}
$name = $this->keyComment();
try {
$fleet->addAuthorizedKey($server, $this->publicKey);
// Force the key's comment to the operator's name so it is identifiable in the list.
$fleet->addAuthorizedKey($server, $this->withComment($this->publicKey, $name));
} catch (Throwable $e) {
$this->error = $e->getMessage();
@ -68,7 +97,7 @@ class AddSshKey extends ModalComponent
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'ssh_key.add',
'target' => $server->name,
'target' => $name !== '' ? $server->name.' · '.$name : $server->name,
'ip' => request()->ip(),
]);

View File

@ -7,6 +7,8 @@ return [
// ── Add SSH key ───────────────────────────────────────────────────────
'add_ssh_key' => [
'title' => 'SSH-Schlüssel hinzufügen',
'name_label' => 'Name (optional)',
'name_placeholder' => 'z.B. Backup-Laptop · Admin-Notebook',
'subtitle_before' => 'Öffentlichen Schlüssel einfügen — er wird in',
'subtitle_after' => 'ergänzt.',
'public_key_label' => 'Öffentlicher Schlüssel',

View File

@ -6,6 +6,8 @@ return [
// ── Add SSH key ───────────────────────────────────────────────────────
'add_ssh_key' => [
'title' => 'Add SSH key',
'name_label' => 'Name (optional)',
'name_placeholder' => 'e.g. Backup laptop · Admin notebook',
'subtitle_before' => 'Paste a public key — it is appended to',
'subtitle_after' => '.',
'public_key_label' => 'Public key',

View File

@ -9,6 +9,12 @@
</div>
</div>
<div class="mt-4">
<label class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.add_ssh_key.name_label') }}</label>
<input wire:model="keyName" type="text" maxlength="64" placeholder="{{ __('modals.add_ssh_key.name_placeholder') }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
</div>
<div class="mt-4">
<div class="mb-1.5 flex items-center justify-between">
<label class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.add_ssh_key.public_key_label') }}</label>

View File

@ -0,0 +1,96 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\AddSshKey;
use App\Models\Server;
use App\Models\User;
use App\Services\FleetService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class AddSshKeyTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function server(): Server
{
return Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
}
/** Mock FleetService and capture the public key actually passed to addAuthorizedKey(). */
private function captureInstalledKey(string &$captured): void
{
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('addAuthorizedKey')->once()
->andReturnUsing(function ($s, $pub) use (&$captured) {
$captured = $pub;
});
app()->instance(FleetService::class, $fleet);
}
public function test_the_form_renders_the_name_field_without_a_leaked_key(): void
{
$this->actingAs(User::factory()->create());
Livewire::test(AddSshKey::class, ['serverId' => $this->server()->id])
->assertSee(__('modals.add_ssh_key.name_label'))
->assertDontSee('add_ssh_key.name_label'); // the translation must resolve, not leak the key
}
public function test_the_name_becomes_the_key_comment(): void
{
$this->actingAs(User::factory()->create());
$server = $this->server();
$captured = '';
$this->captureInstalledKey($captured);
Livewire::test(AddSshKey::class, ['serverId' => $server->id])
->set('keyName', 'Backup Laptop')
->set('publicKey', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc old-comment')
->call('save');
// The pasted comment is replaced by the operator's name so the key is identifiable.
$this->assertSame('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc Backup Laptop', $captured);
}
public function test_a_name_with_newlines_cannot_inject_a_second_key(): void
{
$this->actingAs(User::factory()->create());
$server = $this->server();
$captured = '';
$this->captureInstalledKey($captured);
Livewire::test(AddSshKey::class, ['serverId' => $server->id])
->set('keyName', "Evil\nssh-ed25519 INJECTED")
->set('publicKey', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc')
->call('save');
// Newline collapsed → the would-be injected key stays a harmless comment on ONE line.
$this->assertStringNotContainsString("\n", $captured);
$this->assertSame('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc Evil ssh-ed25519 INJECTED', $captured);
}
public function test_without_a_name_the_pasted_comment_is_kept(): void
{
$this->actingAs(User::factory()->create());
$server = $this->server();
$captured = '';
$this->captureInstalledKey($captured);
Livewire::test(AddSshKey::class, ['serverId' => $server->id])
->set('keyName', '')
->set('publicKey', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc original@host')
->call('save');
$this->assertSame('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc original@host', $captured);
}
}