Add API to manage SSH authorized keys on Home Assistant OS - #7039
Conversation
The OS Agent has long exposed AddSSHAuthKey and ClearSSHAuthKeys on its io.hass.os System D-Bus object, but the Supervisor never wrapped them, so there was no way to manage root's SSH authorized keys through the Supervisor API. Add POST /os/ssh/authorized_keys, which replaces the configured keys with the submitted list (an empty list just clears them). Since OS Agent writes each key verbatim to /root/.ssh/authorized_keys as root, the endpoint validates strictly before anything is written: plain public keys only (no options, no certificates), a key type allowlist matching what dropbear on Home Assistant OS can verify, no control characters (one submitted key can never write more than one line), the base64 blob must embed the declared key type, and entries are capped at dropbear's 3000 byte per-line limit. The endpoint is admin-only for add-on tokens. Replacement clears the existing keys and then adds each key. OS Agent releases up to 1.10.x return an error when clearing an already absent file (inverted error check, since fixed), which is the state of every first-time user, so this specific error is treated as the empty state it reports. dropbear on Home Assistant OS is gated by ConditionFileNotEmpty on the authorized_keys file, which systemd only evaluates when the unit starts, so the service is started after a non-empty key set is written. A running dropbear re-reads the file on every authentication attempt and needs no restart.
There was a problem hiding this comment.
Pull request overview
This PR adds a new Supervisor OS API endpoint to replace HAOS root’s SSH authorized_keys via OS Agent D-Bus methods, providing a write-only management path for debug-console SSH access while validating submitted keys before any host mutation.
Changes:
- Add
POST /os/ssh/authorized_keys, including strict validation of OpenSSH public keys (type allowlist, no control chars/options, base64 and embedded-type checks, 3000-byte line limit). - Implement OS manager logic to clear then append keys via OS Agent D-Bus, and start
dropbear.serviceafter writing a non-empty set. - Extend D-Bus mocks and add comprehensive API + D-Bus interface tests, including role restrictions and error handling paths.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| supervisor/api/init.py | Registers the new /os/ssh/authorized_keys route. |
| supervisor/api/const.py | Adds ATTR_KEYS constant for request schema. |
| supervisor/api/os.py | Implements request schema, SSH public key validator, and API handler. |
| supervisor/api/middleware/security.py | Restricts the new endpoint to admin role for add-on tokens. |
| supervisor/dbus/agent/system.py | Adds OS Agent System proxy methods for adding/clearing SSH keys. |
| supervisor/os/manager.py | Adds job to clear/append keys and start dropbear on HAOS. |
| tests/api/middleware/test_security.py | Ensures add-on token role enforcement includes the new endpoint. |
| tests/api/test_os.py | Adds endpoint tests covering success, validation failures, HAOS absence, OS Agent quirks, and dropbear start errors. |
| tests/dbus/agent/test_system.py | Adds OS Agent System interface tests for new D-Bus calls. |
| tests/dbus_service_mocks/agent_system.py | Extends mock System service with Add/Clear SSH key methods. |
Review discussion questioned the full key validation (type allowlist, base64 blob checks, canonicalization): OS Agent 1.10.0 validates submitted keys itself and treats clearing an already absent authorized_keys file as success, so the Supervisor can rely on it instead of duplicating the logic. Require OS Agent 1.10.0 or newer and reject requests on older releases with 404, like the Raspberry Pi firmware endpoints do; this also makes the missing-file compatibility shim for the clear call unnecessary. A key rejected by OS Agent surfaces as an error response including its validation message. The Supervisor keeps only a basic per-key sanity check that runs before anything is written: no control characters (one submitted key can never write more than one authorized_keys line) and at most 3000 bytes (dropbear ignores longer lines, which would leave a key that passes but never works).
Requiring OS Agent 1.10.0 would keep the feature unavailable until the next OS update reaches users, while Supervisor updates roll out independently. Drop the version requirement and accept that validation is lossy on older releases: the Supervisor sanity check still prevents writing more than one line per key or lines dropbear ignores, but proper key validation only happens on OS Agent 1.10.0 or newer. This brings back the need to tolerate the error OS Agent releases before 1.10.0 return when clearing an already absent authorized_keys file (inverted error check): match the complete os.Remove error message for the authorized_keys path and treat it as the empty state clearing aims for, on affected versions only.
mdegat01
left a comment
There was a problem hiding this comment.
I am not a fan of one upsert API like this. If we want to keep it then it should be PUT or PATCH not POST but I think we should change the API layout per my comment below
| _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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| 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), |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
This seems like a great reason to lay out the API like this:
POST /os/ssh/authorized_keys- accepts exactly one key. Calls the add ssh key in OS agent with itDELETE /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.
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
Review feedback preferred endpoints mapping 1:1 onto the OS Agent D-Bus
methods over a single replace-the-set API, whose POST semantics were
also questioned (an idempotent full replacement would be PUT).
POST /os/ssh/authorized_keys now takes a single key ({"key": "..."})
and appends it via AddSSHAuthKey; DELETE /os/ssh/authorized_keys
removes all keys via ClearSSHAuthKeys. Each call maps to exactly one
OS Agent operation, so no request can partially succeed. Clients that
want to replace the configured set clear and re-add; a GET (which
needs an OS Agent extension first) and an idempotent PUT can be added
later.
The per-key sanity check, the dropbear service start after adding a
key, and the tolerance for the missing-file clear error of OS Agent
releases before 1.10.0 carry over unchanged.
There was a problem hiding this comment.
🟡 Human review recommended
It introduces new security-sensitive functionality (managing root SSH access and starting an SSH service) that warrants final human review despite solid test coverage.
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
mdegat01
left a comment
There was a problem hiding this comment.
I think I'd prefer we move these APIs to core only unless we really want to allow management from apps. And seems like we should stop the dropbear service after the keys are cleared.
| r"|/network/.+" | ||
| r"|/observer/.+" | ||
| r"|/os/(?!datadisk/wipe).+" | ||
| r"|/os/(?!datadisk/wipe|ssh/authorized_keys).+" |
There was a problem hiding this comment.
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.
| ): | ||
| raise HassOSError( | ||
| f"Can't clear SSH authorized keys: {err!s}", _LOGGER.error | ||
| ) from err |
There was a problem hiding this comment.
Shouldn't this stop dropbear service after a successful key clear? Since we've reset to initial state where the authorized key file is empty.
With only add and clear operations the authorized_keys file is write-only for API consumers: a user cannot audit which keys grant access to the box, or whether any exist at all — including keys that were imported from USB or written by add-ons. OS Agent 1.11.0 added a ListSSHAuthKeys D-Bus method. Expose it as GET /os/ssh/authorized_keys, returning the configured entries verbatim. The endpoint requires OS Agent 1.11.0 and returns 404 on older releases, like the Raspberry Pi firmware endpoints do; add and clear keep working on all OS Agent releases.
Proposed change
Add an API to manage root's SSH authorized keys on Home Assistant OS through the Supervisor, wrapping the
AddSSHAuthKeyandClearSSHAuthKeysD-Bus methods of the OS Agentio.hass.osSystem object, which the Supervisor never used so far. This provides the backend for managing SSH access to the HAOS debug console (port 22222). The endpoints map 1:1 onto the OS Agent operations, so no request can partially succeed:GET /os/ssh/authorized_keysreturns the configured keys ({"keys": ["ssh-ed25519 AAAA…", …]}).POST /os/ssh/authorized_keyswith{"key": "ssh-ed25519 AAAA…"}appends a single key.DELETE /os/ssh/authorized_keysremoves all keys.The
GETrequires OS Agent 1.11.0 (which added theListSSHAuthKeysD-Bus method, home-assistant/os-agent#281) and returns 404 on older releases, like the Raspberry Pi firmware endpoints do — this provides the auditability of what keys grant access to the box, including keys imported via USB or written by add-ons. Replacing the configured set means clear and re-add; an idempotentPUTto replace the whole set can be added later.Proper key validation is left to OS Agent, which validates submitted keys since 1.10.0 (home-assistant/os-agent#273); a key it rejects surfaces as an error response including its validation message. The endpoints work with older OS Agent releases too, accepting that validation is lossy there: the Supervisor runs a basic sanity check on the submitted key — no control characters (one submitted key can never write more than one
authorized_keysline) and at most 3000 bytes (dropbear — the consumer of the file on HAOS — ignores longer lines, which would leave a key that passes but never works). On releases before 1.10.0 the error OS Agent returns when clearing an already absentauthorized_keysfile (inverted error check) is treated as the empty state it reports. For add-on tokens both endpoints require the admin role (like/os/datadisk/wipe); requests proxied through Home Assistant Core are unaffected.Since dropbear on HAOS is gated by
ConditionFileNotEmptyon theauthorized_keysfile, which systemd only evaluates at unit start, the service is started after a key is added (a running dropbear re-reads the file on every authentication attempt, and starting an active unit is a no-op).Type of change
Additional information
Related OS Agent pull requests: home-assistant/os-agent#273 (key validation and hardening of the wrapped D-Bus methods, released in OS Agent 1.10.0; add and clear work with both older and newer releases) and the
ListSSHAuthKeysaddition released in OS Agent 1.11.0, which theGETendpoint requires.Checklist
ruff format supervisor tests)If API endpoints or add-on configuration are added/changed: