From de11d68ade248612c5153d087cea29f7bcd6a52b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:44:47 +0000 Subject: [PATCH 1/4] Initial plan From 1ea52853c77e4d316103b029e3a32f04cfe9f98e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:49:18 +0000 Subject: [PATCH 2/4] Add passphrase support for encrypted SSH keys Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- README.md | 2 ++ __init__.py | 11 ++++++++++- const.py | 1 + coordinator.py | 4 ++++ services.yaml | 6 ++++++ strings.json | 7 +++++++ tests/unit_tests/test_async_execute.py | 19 +++++++++++++++++++ .../unit_tests/test_validate_service_data.py | 5 +++++ translations/de.json | 7 +++++++ translations/en.json | 7 +++++++ 10 files changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de17101..e787552 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This integration does not create devices or entities. It only registers the `ssh - `username` (required) — SSH username - `password` — SSH password (use instead of key_file) - `key_file` — Path to an SSH private key file (use instead of password) +- `passphrase` — Passphrase for an encrypted private key file (only valid when `key_file` is used) - `command` — Command string to execute on the host - `input` — Input to send to the `stdin` of the host. If this is a file path, the content of the file will be sent. - `check_known_hosts` (default: `true`) — Verify host key against known hosts @@ -54,6 +55,7 @@ All parameters are optional in the raw schema except `host` and `username` — t #### Validation rules enforced by the service - Either `password` or `key_file` must be provided, but not both +- `passphrase` may only be provided when `key_file` is used - Either `command` or `input` or both must be provided - If `key_file` is provided, the file must exist on the Home Assistant filesystem - `known_hosts` may not be provided when `check_known_hosts` is `false` diff --git a/__init__.py b/__init__.py index 23f39f0..10f3b08 100644 --- a/__init__.py +++ b/__init__.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, SERVICE_EXECUTE, CONF_KEY_FILE, CONF_INPUT, CONST_DEFAULT_TIMEOUT, \ +from .const import DOMAIN, SERVICE_EXECUTE, CONF_KEY_FILE, CONF_PASSPHRASE, CONF_INPUT, CONST_DEFAULT_TIMEOUT, \ CONF_CHECK_KNOWN_HOSTS, CONF_KNOWN_HOSTS, CONF_PORT from .coordinator import SshCommandCoordinator @@ -23,6 +23,7 @@ async def _validate_service_data(hass: HomeAssistant, data: dict[str, Any]) -> None: has_password: bool = bool(data.get(CONF_PASSWORD)) has_key_file: bool = bool(data.get(CONF_KEY_FILE)) + has_passphrase: bool = bool(data.get(CONF_PASSPHRASE)) if not has_password and not has_key_file: raise ServiceValidationError( @@ -38,6 +39,13 @@ async def _validate_service_data(hass: HomeAssistant, data: dict[str, Any]) -> N translation_key="password_and_key_file", ) + if has_passphrase and not has_key_file: + raise ServiceValidationError( + "Passphrase can only be used when key_file is provided.", + translation_domain=DOMAIN, + translation_key="passphrase_requires_key_file", + ) + has_command: bool = bool(data.get(CONF_COMMAND)) has_input: bool = bool(data.get(CONF_INPUT)) @@ -73,6 +81,7 @@ async def _validate_service_data(hass: HomeAssistant, data: dict[str, Any]) -> N vol.Required(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str, vol.Optional(CONF_KEY_FILE): str, + vol.Optional(CONF_PASSPHRASE): str, vol.Optional(CONF_COMMAND): str, vol.Optional(CONF_INPUT): str, vol.Optional(CONF_CHECK_KNOWN_HOSTS, default=True): bool, diff --git a/const.py b/const.py index cb5ddab..5ab247b 100644 --- a/const.py +++ b/const.py @@ -5,6 +5,7 @@ CONF_PORT = "port" CONF_CLIENT_KEYS = "client_keys" CONF_KEY_FILE = "key_file" +CONF_PASSPHRASE = "passphrase" CONF_INPUT = "input" CONF_CHECK = "check" CONF_CHECK_KNOWN_HOSTS = "check_known_hosts" diff --git a/coordinator.py b/coordinator.py index 1300cee..ab04219 100644 --- a/coordinator.py +++ b/coordinator.py @@ -44,6 +44,7 @@ from .const import ( DOMAIN, CONF_KEY_FILE, + CONF_PASSPHRASE, CONF_INPUT, CONF_CHECK_KNOWN_HOSTS, CONF_KNOWN_HOSTS, @@ -75,6 +76,7 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]: username = data.get(CONF_USERNAME) password = data.get(CONF_PASSWORD) key_file = data.get(CONF_KEY_FILE) + passphrase = data.get(CONF_PASSPHRASE) command = data.get(CONF_COMMAND) input_data = data.get(CONF_INPUT) check_known_hosts = data.get(CONF_CHECK_KNOWN_HOSTS, True) @@ -94,6 +96,8 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]: CONF_KNOWN_HOSTS: await self._resolve_known_hosts(check_known_hosts, known_hosts), CONF_CONNECTION_TIMEOUT: timeout, } + if passphrase: + conn_kwargs[CONF_PASSPHRASE] = passphrase run_kwargs: dict[str, Any] = { CONF_COMMAND: command, diff --git a/services.yaml b/services.yaml index 96c9fbe..89fc9b3 100644 --- a/services.yaml +++ b/services.yaml @@ -34,6 +34,12 @@ execute: required: false selector: text: + passphrase: + name: Passphrase + description: Passphrase for the private key file. Only valid when `key_file` is set. + required: false + selector: + text: command: name: Command description: The command to execute on the host. diff --git a/strings.json b/strings.json index 1d6e635..4f00be2 100644 --- a/strings.json +++ b/strings.json @@ -30,6 +30,10 @@ "name": "Key File", "description": "Path to the SSH private key file for key-based authentication." }, + "passphrase": { + "name": "Passphrase", + "description": "Passphrase for the SSH private key file, if the key is encrypted." + }, "command": { "name": "Command", "description": "The command to execute on the machine." @@ -66,6 +70,9 @@ "key_file_not_found": { "message": "Could not find key file." }, + "passphrase_requires_key_file": { + "message": "Passphrase can only be used when key_file is provided." + }, "invalid_key_file": { "message": "The key file is not a valid private key." }, diff --git a/tests/unit_tests/test_async_execute.py b/tests/unit_tests/test_async_execute.py index c268587..c513bc5 100644 --- a/tests/unit_tests/test_async_execute.py +++ b/tests/unit_tests/test_async_execute.py @@ -145,6 +145,25 @@ async def test_other_oserror_is_reraised(self): with self.assertRaises(OSError): await self.handler(service_call) + async def test_passphrase_is_forwarded_to_connect(self): + mock_conn = self._make_mock_conn(stdout="ok", stderr="", exit_status=0) + data = { + "host": "192.0.2.1", + "username": "user", + "key_file": "/home/user/.ssh/id_rsa", + "passphrase": "topsecret", + "command": "echo hello", + "check_known_hosts": False, + } + service_call = self._make_service_call(data) + + with patch("pathlib.Path.exists", return_value=True): + with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)) as mock_connect: + await self.handler(service_call) + + call_kwargs = mock_connect.call_args[1] + self.assertEqual(call_kwargs["passphrase"], "topsecret") + async def test_input_from_file(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tf: tf.write("file content\n") diff --git a/tests/unit_tests/test_validate_service_data.py b/tests/unit_tests/test_validate_service_data.py index 4e5b443..cd75583 100644 --- a/tests/unit_tests/test_validate_service_data.py +++ b/tests/unit_tests/test_validate_service_data.py @@ -34,6 +34,11 @@ async def test_password_and_key_file_raises(self): await _validate_service_data(self.mock_hass, {"password": "secret", "key_file": "/home/user/.ssh/id_rsa", "command": "echo hi"}) self.assertEqual(ctx.exception.translation_key, "password_and_key_file") + async def test_passphrase_without_key_file_raises(self): + with self.assertRaises(ServiceValidationError) as ctx: + await _validate_service_data(self.mock_hass, {"password": "secret", "passphrase": "topsecret", "command": "echo hi"}) + self.assertEqual(ctx.exception.translation_key, "passphrase_requires_key_file") + async def test_no_command_no_input_raises(self): with self.assertRaises(ServiceValidationError) as ctx: await _validate_service_data(self.mock_hass, {"password": "secret"}) diff --git a/translations/de.json b/translations/de.json index 3041393..dce602c 100644 --- a/translations/de.json +++ b/translations/de.json @@ -30,6 +30,10 @@ "name": "Schlüsseldatei", "description": "Der Pfad zur privaten Schlüsseldatei für die SSH-Anmeldung" }, + "passphrase": { + "name": "Passphrase", + "description": "Passphrase für die SSH-Schlüsseldatei, falls der Schlüssel verschlüsselt ist." + }, "command": { "name": "Befehl", "description": "Der Befehl, der auf dem Host ausgeführt werden soll." @@ -66,6 +70,9 @@ "key_file_not_found": { "message": "Konnte Schlüsseldatei nicht finden." }, + "passphrase_requires_key_file": { + "message": "Passphrase kann nur verwendet werden, wenn key_file angegeben ist." + }, "invalid_key_file": { "message": "Die Schlüsseldatei ist kein gültiger privater Schlüssel." }, diff --git a/translations/en.json b/translations/en.json index 1d6e635..4f00be2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -30,6 +30,10 @@ "name": "Key File", "description": "Path to the SSH private key file for key-based authentication." }, + "passphrase": { + "name": "Passphrase", + "description": "Passphrase for the SSH private key file, if the key is encrypted." + }, "command": { "name": "Command", "description": "The command to execute on the machine." @@ -66,6 +70,9 @@ "key_file_not_found": { "message": "Could not find key file." }, + "passphrase_requires_key_file": { + "message": "Passphrase can only be used when key_file is provided." + }, "invalid_key_file": { "message": "The key file is not a valid private key." }, From 1e830300ae0d11f8635cfb7616724d9e992d1366 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:47:28 +0000 Subject: [PATCH 3/4] Gracefully handle invalid SSH key passphrases and mask them in UI Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- __init__.py | 2 +- coordinator.py | 19 ++++++++- services.yaml | 2 + strings.json | 3 ++ tests/unit_tests/test_async_execute.py | 39 ++++++++++++++++++- .../unit_tests/test_validate_service_data.py | 4 ++ translations/de.json | 3 ++ translations/en.json | 3 ++ 8 files changed, 71 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index 10f3b08..52941c9 100644 --- a/__init__.py +++ b/__init__.py @@ -23,7 +23,7 @@ async def _validate_service_data(hass: HomeAssistant, data: dict[str, Any]) -> None: has_password: bool = bool(data.get(CONF_PASSWORD)) has_key_file: bool = bool(data.get(CONF_KEY_FILE)) - has_passphrase: bool = bool(data.get(CONF_PASSPHRASE)) + has_passphrase: bool = data.get(CONF_PASSPHRASE) is not None if not has_password and not has_key_file: raise ServiceValidationError( diff --git a/coordinator.py b/coordinator.py index ab04219..4efa8b4 100644 --- a/coordinator.py +++ b/coordinator.py @@ -34,7 +34,15 @@ ) sys.modules["fido2.client.windows"] = None # type: ignore[assignment] -from asyncssh import HostKeyNotVerifiable, KeyImportError, PermissionDenied, connect, read_known_hosts, DEFAULT_PORT +from asyncssh import ( + HostKeyNotVerifiable, + KeyEncryptionError, + KeyImportError, + PermissionDenied, + connect, + read_known_hosts, + DEFAULT_PORT, +) from .const import CONF_CONNECTION_TIMEOUT from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_COMMAND, CONF_TIMEOUT @@ -96,7 +104,7 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]: CONF_KNOWN_HOSTS: await self._resolve_known_hosts(check_known_hosts, known_hosts), CONF_CONNECTION_TIMEOUT: timeout, } - if passphrase: + if passphrase is not None: conn_kwargs[CONF_PASSPHRASE] = passphrase run_kwargs: dict[str, Any] = { @@ -118,6 +126,13 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]: translation_domain=DOMAIN, translation_key="host_key_not_verifiable", ) from exc + except KeyEncryptionError as exc: + _LOGGER.warning("Invalid passphrase for %s@%s: %s", username, host, exc) + raise ServiceValidationError( + "The key file passphrase is invalid.", + translation_domain=DOMAIN, + translation_key="invalid_key_passphrase", + ) from exc except KeyImportError as exc: _LOGGER.warning("Invalid key file for %s@%s: %s", username, host, exc) raise ServiceValidationError( diff --git a/services.yaml b/services.yaml index 89fc9b3..7e451ee 100644 --- a/services.yaml +++ b/services.yaml @@ -28,6 +28,7 @@ execute: required: false selector: text: + type: password key_file: name: Key File description: Path to the SSH private key file for key-based authentication (optional if using username/password). @@ -40,6 +41,7 @@ execute: required: false selector: text: + type: password command: name: Command description: The command to execute on the host. diff --git a/strings.json b/strings.json index 4f00be2..d8d2f65 100644 --- a/strings.json +++ b/strings.json @@ -73,6 +73,9 @@ "passphrase_requires_key_file": { "message": "Passphrase can only be used when key_file is provided." }, + "invalid_key_passphrase": { + "message": "The key file passphrase is invalid." + }, "invalid_key_file": { "message": "The key file is not a valid private key." }, diff --git a/tests/unit_tests/test_async_execute.py b/tests/unit_tests/test_async_execute.py index c513bc5..a9891be 100644 --- a/tests/unit_tests/test_async_execute.py +++ b/tests/unit_tests/test_async_execute.py @@ -12,7 +12,7 @@ absolute_plugin_path = str(Path(__file__).parent.parent.parent.parent.absolute()) sys.path.insert(0, absolute_plugin_path) -from asyncssh import HostKeyNotVerifiable, KeyImportError, PermissionDenied +from asyncssh import HostKeyNotVerifiable, KeyEncryptionError, KeyImportError, PermissionDenied from homeassistant.exceptions import ServiceValidationError @@ -109,6 +109,24 @@ async def test_invalid_key_file(self): self.assertEqual(ctx.exception.translation_key, "invalid_key_file") + async def test_invalid_passphrase(self): + data = { + "host": "192.0.2.1", + "username": "user", + "key_file": "/home/user/.ssh/id_rsa", + "passphrase": "wrong", + "command": "echo hello", + "check_known_hosts": False, + } + service_call = self._make_service_call(data) + + with patch("pathlib.Path.exists", return_value=True): + with patch("ssh_command.coordinator.connect", return_value=_MockConnectRaises(KeyEncryptionError("Incorrect passphrase"))): + with self.assertRaises(ServiceValidationError) as ctx: + await self.handler(service_call) + + self.assertEqual(ctx.exception.translation_key, "invalid_key_passphrase") + async def test_permission_denied(self): service_call = self._make_service_call(SERVICE_DATA_BASE) @@ -164,6 +182,25 @@ async def test_passphrase_is_forwarded_to_connect(self): call_kwargs = mock_connect.call_args[1] self.assertEqual(call_kwargs["passphrase"], "topsecret") + async def test_empty_passphrase_is_forwarded_to_connect(self): + mock_conn = self._make_mock_conn(stdout="ok", stderr="", exit_status=0) + data = { + "host": "192.0.2.1", + "username": "user", + "key_file": "/home/user/.ssh/id_rsa", + "passphrase": "", + "command": "echo hello", + "check_known_hosts": False, + } + service_call = self._make_service_call(data) + + with patch("pathlib.Path.exists", return_value=True): + with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)) as mock_connect: + await self.handler(service_call) + + call_kwargs = mock_connect.call_args[1] + self.assertEqual(call_kwargs["passphrase"], "") + async def test_input_from_file(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tf: tf.write("file content\n") diff --git a/tests/unit_tests/test_validate_service_data.py b/tests/unit_tests/test_validate_service_data.py index cd75583..12831eb 100644 --- a/tests/unit_tests/test_validate_service_data.py +++ b/tests/unit_tests/test_validate_service_data.py @@ -39,6 +39,10 @@ async def test_passphrase_without_key_file_raises(self): await _validate_service_data(self.mock_hass, {"password": "secret", "passphrase": "topsecret", "command": "echo hi"}) self.assertEqual(ctx.exception.translation_key, "passphrase_requires_key_file") + async def test_empty_passphrase_with_key_file_is_allowed(self): + with patch("pathlib.Path.exists", return_value=True): + await _validate_service_data(self.mock_hass, {"key_file": "/home/user/.ssh/id_rsa", "passphrase": "", "input": "some text"}) + async def test_no_command_no_input_raises(self): with self.assertRaises(ServiceValidationError) as ctx: await _validate_service_data(self.mock_hass, {"password": "secret"}) diff --git a/translations/de.json b/translations/de.json index dce602c..7534879 100644 --- a/translations/de.json +++ b/translations/de.json @@ -73,6 +73,9 @@ "passphrase_requires_key_file": { "message": "Passphrase kann nur verwendet werden, wenn key_file angegeben ist." }, + "invalid_key_passphrase": { + "message": "Die Passphrase für die Schlüsseldatei ist ungültig." + }, "invalid_key_file": { "message": "Die Schlüsseldatei ist kein gültiger privater Schlüssel." }, diff --git a/translations/en.json b/translations/en.json index 4f00be2..d8d2f65 100644 --- a/translations/en.json +++ b/translations/en.json @@ -73,6 +73,9 @@ "passphrase_requires_key_file": { "message": "Passphrase can only be used when key_file is provided." }, + "invalid_key_passphrase": { + "message": "The key file passphrase is invalid." + }, "invalid_key_file": { "message": "The key file is not a valid private key." }, From 64f164b3b1a45df30118fa37c086888855dde2b3 Mon Sep 17 00:00:00 2001 From: gensyn <36128035+gensyn@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:59:05 +0200 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- __init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 52941c9..3bfa58b 100644 --- a/__init__.py +++ b/__init__.py @@ -13,8 +13,17 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, SERVICE_EXECUTE, CONF_KEY_FILE, CONF_PASSPHRASE, CONF_INPUT, CONST_DEFAULT_TIMEOUT, \ - CONF_CHECK_KNOWN_HOSTS, CONF_KNOWN_HOSTS, CONF_PORT +from .const import ( + CONF_CHECK_KNOWN_HOSTS, + CONF_INPUT, + CONF_KEY_FILE, + CONF_KNOWN_HOSTS, + CONF_PASSPHRASE, + CONF_PORT, + CONST_DEFAULT_TIMEOUT, + DOMAIN, + SERVICE_EXECUTE, +) from .coordinator import SshCommandCoordinator CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) # pylint: disable=invalid-name