diff --git a/app/Services/SshKeyProvisioner.php b/app/Services/SshKeyProvisioner.php index 5bd8a24..3dd3175 100644 --- a/app/Services/SshKeyProvisioner.php +++ b/app/Services/SshKeyProvisioner.php @@ -22,6 +22,10 @@ class SshKeyProvisioner ) {} /** + * `privateKey` may be present even on `ok:false` — when password auth failed to disable AFTER the + * credential was already switched to the verified key. Callers MUST display it to the operator and + * MUST NOT log or persist it (it is the plaintext private key). + * * @return array{ok: bool, privateKey?: string, publicKey?: string, error?: string} */ public function enableKeyOnlyAccess(Server $server): array @@ -72,11 +76,20 @@ class SshKeyProvisioner } // 4) Disable password auth (the hardening guard passes now: credential is key + key installed). - $res = $this->hardening->apply($server, 'ssh_password', false); + // The credential is already the verified key, so any failure here still leaves the operator + // with working key access — never a lockout. + try { + $res = $this->hardening->apply($server, 'ssh_password', false); + } catch (Throwable $e) { + return array_filter([ + 'ok' => false, 'error' => $e->getMessage(), 'privateKey' => $privateKey, 'publicKey' => $publicKey, + ], fn ($v) => $v !== null); + } if (! $res['ok']) { - // The credential is already switched, so key access remains — just report the failure. - return ['ok' => false, 'error' => $res['output'], 'privateKey' => $privateKey, 'publicKey' => $publicKey]; + return array_filter([ + 'ok' => false, 'error' => $res['output'], 'privateKey' => $privateKey, 'publicKey' => $publicKey, + ], fn ($v) => $v !== null); } return array_filter([ diff --git a/tests/Feature/SshKeyProvisionerTest.php b/tests/Feature/SshKeyProvisionerTest.php index 81716b8..626061d 100644 --- a/tests/Feature/SshKeyProvisionerTest.php +++ b/tests/Feature/SshKeyProvisionerTest.php @@ -102,4 +102,23 @@ class SshKeyProvisionerTest extends TestCase $this->assertFalse($result['ok']); } + + public function test_apply_failure_after_switch_keeps_key_credential_and_returns_the_private_key(): void + { + $server = $this->passwordServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('addAuthorizedKey')->once(); + $fleet->shouldReceive('testConnection')->once()->andReturn(['ok' => true, 'error' => null]); + + $hardening = Mockery::mock(HardeningService::class); + $hardening->shouldReceive('apply')->once()->andReturn(['ok' => false, 'output' => 'sshd reload failed']); + + $result = (new SshKeyProvisioner($fleet, $hardening))->enableKeyOnlyAccess($server); + + $this->assertFalse($result['ok']); + $this->assertNotEmpty($result['privateKey']); // UI can still reveal it + // Credential stays the verified key — operator keeps access (NOT rolled back). + $this->assertSame('key', $server->fresh('credential')->credential->auth_type); + } }