Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions supervisor/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ def _register_os(self, app: web.Application) -> None:
web.get("/os/datadisk/list", api_os.list_data),
web.post("/os/datadisk/wipe", api_os.wipe_data),
web.post("/os/boot-slot", api_os.set_boot_slot),
web.post("/os/ssh/authorized_keys", api_os.ssh_authorized_keys),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we truly want this endpoint to replace the entire authorized keys file with this new set of keys and not add a key to the set we should use PUT here not POSTimo. Per mozilla guidelines:

The PUT HTTP method creates a new resource or replaces a representation of the target resource with the request content.

The difference between PUT and POST is that PUT is idempotent: calling it once is no different from calling it several times successively (there are no side effects).

As defined this is idempotent and replaces the resource (authorized key file in this case) so PUT is the better fit. But personally I would prefer this use POST, not be idempotent, and just append one or more keys to the existing file.

]
)

Expand Down
1 change: 1 addition & 0 deletions supervisor/api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
ATTR_IS_ACTIVE = "is_active"
ATTR_IS_OWNER = "is_owner"
ATTR_JOBS = "jobs"
ATTR_KEYS = "keys"
ATTR_LLMNR = "llmnr"
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
ATTR_LOCAL_ONLY = "local_only"
Expand Down
4 changes: 2 additions & 2 deletions supervisor/api/middleware/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ class _AppSecurityPatterns:
r"|/multicast/.+"
r"|/network/.+"
r"|/observer/.+"
r"|/os/(?!datadisk/wipe).+"
r"|/os/(?!datadisk/wipe|ssh/authorized_keys).+"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make this a core only endpoint rather then a manager one? Putting it in manager would give many apps the ability to call it. I know its hardly the only attack avenue if you consider the idea of a malicious app but it still just doesn't seem like capability we want to allow apps to do.

The downside would be the SSH app also can't call it which is probably the one app we'd prefer to allow. But as long as we make the proposed UI this seems like an acceptable situation to only allow host ssh key management from HA UI and the host shell itself.

r"|/refresh_updates"
r"|/resolution/.+"
r"|/security/.+"
Expand Down Expand Up @@ -226,7 +226,7 @@ class _AppSecurityPatterns:
r"|/multicast/.+"
r"|/network/.+"
r"|/observer/.+"
r"|/os/(?!datadisk/wipe).+"
r"|/os/(?!datadisk/wipe|ssh/authorized_keys).+"
r"|/reload_updates"
r"|/resolution/.+"
r"|/security/.+"
Expand Down
36 changes: 36 additions & 0 deletions supervisor/api/os.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
ATTR_DEV_PATH,
ATTR_DEVICE,
ATTR_DISKS,
ATTR_KEYS,
ATTR_MODEL,
ATTR_STATUS,
ATTR_SYSTEM_HEALTH_LED,
Expand Down Expand Up @@ -89,6 +90,35 @@
vol.Optional(ATTR_SWAPPINESS): vol.All(int, vol.Range(min=0, max=200)),
}
)

# dropbear, which consumes authorized_keys on Home Assistant OS, ignores
# lines longer than 3000 bytes
SSH_AUTH_KEY_MAX_LENGTH = 3000

RE_SSH_KEY_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
Comment thread
agners marked this conversation as resolved.


def ssh_auth_key(value: Any) -> str:
"""Run a basic sanity check on an SSH authorized key entry.

Proper key validation is done by OS Agent; reject only what could write
more than one authorized_keys line per key (control characters) or
produce a line dropbear ignores (too long).
"""
if not isinstance(value, str):
raise vol.Invalid("SSH public key must be a string")

key = value.strip()
# dropbear and OS Agent limit the line length in bytes, not characters
if not key or len(key.encode()) > SSH_AUTH_KEY_MAX_LENGTH:
raise vol.Invalid("SSH public key is empty or too long")
if RE_SSH_KEY_CONTROL_CHARS.search(key):
raise vol.Invalid("SSH public key contains control characters")

return key


SCHEMA_SSH_AUTHORIZED_KEYS = vol.Schema({vol.Required(ATTR_KEYS): [ssh_auth_key]})
# pylint: enable=no-value-for-parameter


Expand Down Expand Up @@ -153,6 +183,12 @@ async def set_boot_slot(self, request: web.Request) -> None:
body = await api_validate(SCHEMA_SET_BOOT_SLOT, request)
await asyncio.shield(self.sys_os.set_boot_slot(body[ATTR_BOOT_SLOT]))

@api_process
async def ssh_authorized_keys(self, request: web.Request) -> None:
"""Replace root's SSH authorized keys on the host."""
body = await api_validate(SCHEMA_SSH_AUTHORIZED_KEYS, request)
await asyncio.shield(self.sys_os.set_ssh_authorized_keys(body[ATTR_KEYS]))

@api_process
async def list_data(self, request: web.Request) -> dict[str, Any]:
"""Return possible data targets."""
Expand Down
14 changes: 14 additions & 0 deletions supervisor/dbus/agent/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,17 @@ async def schedule_wipe_device(self) -> bool:
async def migrate_docker_storage_driver(self, backend: str) -> None:
"""Migrate Docker storage driver."""
await self.connected_dbus.System.call("migrate_docker_storage_driver", backend)

@dbus_connected
async def add_ssh_auth_key(self, key: str) -> None:
"""Append a public key to root's SSH authorized keys on the host.

OS Agent validates the key since 1.10.0; older releases write the
string verbatim to the authorized_keys file.
"""
await self.connected_dbus.System.call("add_ssh_auth_key", key)

@dbus_connected
async def clear_ssh_auth_keys(self) -> None:
"""Remove all of root's SSH authorized keys on the host."""
await self.connected_dbus.System.call("clear_ssh_auth_keys")
64 changes: 64 additions & 0 deletions supervisor/os/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
from ..exceptions import (
DBusError,
DBusNotConnectedError,
HassOSError,
HassOSJobError,
HassOSSlotNotFound,
HassOSSlotUpdateError,
HassOSUpdateError,
HostError,
)
from ..jobs.const import JobConcurrency, JobCondition
from ..jobs.decorator import Job
Expand All @@ -27,6 +29,16 @@

_LOGGER: logging.Logger = logging.getLogger(__name__)

# SSH service on Home Assistant OS consuming /root/.ssh/authorized_keys
DROPBEAR_SERVICE = "dropbear.service"

# OS Agent releases before this return the os.Remove error when clearing an
# already absent authorized_keys file (inverted error check)
CLEAR_SSH_AUTH_KEYS_FIXED_VERSION = AwesomeVersion("1.10.0")
CLEAR_SSH_AUTH_KEYS_MISSING_FILE_ERROR = (
"remove /root/.ssh/authorized_keys: no such file or directory"
)


@dataclass(slots=True, frozen=True)
class SlotStatus:
Expand Down Expand Up @@ -501,3 +513,55 @@ async def set_boot_slot(self, boot_name: str) -> None:

_LOGGER.info("Rebooting into new boot slot now")
await self.sys_host.control.reboot()

@Job(
name="os_manager_set_ssh_authorized_keys",
conditions=[JobCondition.HAOS],
on_condition=HassOSJobError,
concurrency=JobConcurrency.REJECT,
internal=True,
)
async def set_ssh_authorized_keys(self, keys: list[str]) -> None:
"""Replace root's SSH authorized keys on the host and start dropbear.

OS Agent validates each key since 1.10.0 and only offers clear and
append operations, so the replacement is not atomic: if an append is
rejected or fails, keys added before it remain in place.

@mdegat01 mdegat01 Jul 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a great reason to lay out the API like this:

  1. POST /os/ssh/authorized_keys - accepts exactly one key. Calls the add ssh key in OS agent with it
  2. DELETE /os/ssh/authorized_keys - accepts no arguments. Calls clear ssh auth keys in OS Agent

This makes the API easy to use, easy to translate to the CLI and ensures that no API call can partially succeed by having some OS Agent operations succeed before one fails. Since each API maps 1:1 with an OS Agent DBus API.

"""
_LOGGER.info("Replacing SSH authorized keys on host (%d keys)", len(keys))
try:
await self.sys_dbus.agent.system.clear_ssh_auth_keys()
except DBusError as err:
# On affected OS Agent releases the missing-file error is the
# empty state clearing aims for, so treat it as success there.
if (
self.sys_dbus.agent.version >= CLEAR_SSH_AUTH_KEYS_FIXED_VERSION
or CLEAR_SSH_AUTH_KEYS_MISSING_FILE_ERROR not in str(err)
):
raise HassOSError(
f"Can't clear SSH authorized keys: {err!s}", _LOGGER.error
) from err

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to require users pass in every key every time? Can we offer a way to add to existing keys instead of replacing the full list each time? Since OSAgent offers a designated clear authorized keys API it seems unnecessary frustrating to make one API that does an upsert. Wouldn't it be easier to have a POST API that adds one or more keys and a DELETE API that clears the file?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah agreed mirroring the OS Agent API makes more sense. Maybe we should have a get anyways at one point. I think I was concerned about unnecessary information leak when initially created the OS Agent implementation, but maybe that was a bit overly cautious.


for key in keys:
try:
await self.sys_dbus.agent.system.add_ssh_auth_key(key)
except DBusError as err:
raise HassOSError(
f"Can't add SSH authorized key: {err!s}", _LOGGER.error
) from err

if not keys:
return

# dropbear on Home Assistant OS is gated by
# ConditionFileNotEmpty=/root/.ssh/authorized_keys, which systemd only
# evaluates when the unit starts. A running dropbear re-reads the file
# on every authentication attempt and starting an active unit is a
# no-op, so only the stopped service needs this.
try:
await self.sys_host.services.start(DROPBEAR_SERVICE)
except (HostError, DBusError) as err:
raise HassOSError(
f"SSH authorized keys written, but can't start dropbear: {err!s}",
_LOGGER.error,
) from err
1 change: 1 addition & 0 deletions tests/api/middleware/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ def _versioned_path(prefix: str, path: str) -> str:
("post", "/addons/abc123/restart", {"admin", "manager"}),
("post", "/addons/abc123/security", {"admin"}),
("post", "/os/datadisk/wipe", {"admin"}),
("post", "/os/ssh/authorized_keys", {"admin"}),
("post", "/addons/self/sys_options", set()),
("post", "/addons/abc123/sys_options", set()),
],
Expand Down
Loading