/claim #7724
Sporadic Permission denied (publickey,password) errors occur because the SSH subsystem can silently use a stale private key to authenticate. Three independent failure modes combine to cause this:
validateSshKey() runs ls to confirm the file exists on disk, but never verifies its content matches the database. When a user rotates their SSH key (updates private_key in the DB), the old key file remains on disk and SSH happily authenticates with it — until it doesn’t.
Even if the key file is eventually refreshed, an existing mux socket stays open and continues authenticating with the key that was active when the connection was first established. There is no mechanism to detect that the underlying key has changed.
SSH clients refuse private keys that are group- or world-readable (0644). Laravel’s local Storage driver’s put() does not guarantee 0600 permissions on every write, especially after container restarts or volume remounts.
// Before: only checks file existence
private static function validateSshKey(PrivateKey $privateKey): void
{
$keyLocation = $privateKey->getKeyLocation();
$checkKeyCommand = "ls $keyLocation 2>/dev/null";
$keyCheckProcess = Process::run($checkKeyCommand);
if ($keyCheckProcess->exitCode() !== 0) {
$privateKey->storeInFileSystem();
}
}
This passes as long as any file exists at that path — even if it contains a completely different (old) key.
SshMultiplexingHelper::validateSshKey() — content-aware validationPrivateKey model from the database (PrivateKey::find()) to bypass Eloquent’s in-memory relationship cache that may hold a stale version from earlier in the request0600 permissions on every invocation via ensureKeyPermissions()SshMultiplexingHelper::ensureMultiplexedConnection() — fingerprint trackingstoreConnectionMetadata() now caches the SSH key fingerprint alongside the connection timestampisKeyStaleForConnection() compares the cached fingerprint against the current key; triggers refreshMultiplexedConnection() on mismatchclearConnectionMetadata() cleans up both cache entriesServer::saved() — immediate mux teardown on key reassignmentwasChanged('private_key_id') in the saved hookSshMultiplexingHelper::removeMuxFile() so the next SSH command establishes a fresh connection with the new key| File | Change |
|---|---|
app/Helpers/SshMultiplexingHelper.php |
Content validation, permission enforcement, fingerprint tracking, mux invalidation |
app/Models/Server.php |
Mux teardown on private_key_id change |
The fix operates at three layers to ensure no stale key can be used:
generateSshCommand() call validates the key file content and permissionsensureMultiplexedConnection() call checks the key fingerprint against the one used when the mux was establishedSshRetryable trait) continues to work as a complementary safety netLimitless
@Limitless2023
Zach Latta
@zachlatta