In-Memory SSH Authorized Keys
Summary
Replace file-based browser-terminal SSH authorization with an OpenSSH
AuthorizedKeysCommand integration backed by the Nixstasis client runtime. The
Phoenix server still issues short-lived terminal keys after operator
authorization. Managed devices store those public keys in memory only, and sshd
asks a small local helper whether an offered key blob is currently authorized for
the support account.
This feature removes new-install dependence on writing browser-terminal keys to
authorized_keys, keeps operator repair login separate from the nixstasis
service account, and allows the client daemon to move toward an unprivileged
runtime without broad filesystem write authority over operator SSH state.
Goals
- Authenticate browser terminal SSH sessions through OpenSSH
AuthorizedKeysCommandinstead of persistent browser-terminal key files. - Keep ephemeral terminal public keys in client memory only.
- Migrate browser terminal SSH login from the current
nixstasisaccount to the dedicatednixstasis-supportrepair account. - Run the OpenSSH helper as a separate locked-down
nixstasis-ssh-authorityidentity, not asnixstasis-supportand not as a privileged command runner. - Expose a local-only Unix-domain IPC endpoint under
/run/nixstasis/for key authorization checks. - Make browser terminal startup always use the dynamic auth path: the in-memory
ssh_authorizepayload is the only terminal authorization shape, and the client writes the offered key into its in-memory store via a Unix-domain IPC query fromAuthorizedKeysCommand. - Keep browser/operator authorization in Phoenix and Caddy separate from device-side SSH key authorization.
Non-Goals
- Replacing FRP or changing the FRPS TCP mux route.
- Replacing OpenSSH with a custom remote shell daemon.
- Supporting password-based support login.
- Installing permanent operator SSH keys or long-lived per-operator device-local accounts.
- Building a general privileged command execution framework.
- Granting broad sudo or run0 privileges to the
nixstasisservice account or the dynamic-key helper. - Deleting legacy operator-owned
authorized_keysfiles during package upgrade.
Current Behavior
- The server creates ephemeral SSH keys for terminal sessions and stores private
key material behind an expiring session ref in
Nixstasis.Devices.SshKeyManager. - The device detail LiveView currently queues
ssh_authorizebefore creating the terminal session ref. - The heartbeat renderer currently serializes command id, type, args, payload,
payload ref, and queued time; it does not serialize top-level
public_keyfor queued command maps unless that boundary is updated. - The Go client handles
ssh_authorizeby resolving an allowedauthorized_keyspath and appending the provided public key to that file. - The packaged default currently includes
runtime.authorized_keys_pathunder/var/lib/nixstasis/.ssh/authorized_keys. - The server-side SSH client currently connects as
nixstasis@atom-<device>-sshthrough the FRP TCP mux route. - The file-based path requires the client daemon to write into account SSH state, which is the wrong privilege boundary for an unprivileged client.
Proposed Runtime Contract
There is no capability advertisement. The dynamic JSON ssh_authorize payload
is the only path the server emits, and the Go client always writes offered keys
into its in-memory store. There is no legacy file-based fallback, and there is
no capabilities field on the heartbeat. The server treats every authenticated
device as dynamic-capable for terminal authorization, and the in-memory store
is empty on every client restart, which is the natural invalidation point.
ssh_authorize command payload
Every terminal authorization queues a command with explicit SSH auth metadata
and no authorized_keys_path:
{
"command_id": "<command-id>",
"type": "ssh_authorize",
"public_key": "ssh-ed25519 AAAA... nixstasis-remote-access",
"payload": {
"content_type": "application/vnd.nixstasis.ssh-authorize+json;version=1",
"name": "<session-ref>",
"data": "{\"target_user\":\"nixstasis-support\",\"ttl_seconds\":300,\"session_ref\":\"<session-ref>\"}"
}
}
Semantics:
public_keyis the full authorized-keys-style public key generated by the server.target_useris required and must benixstasis-support.ttl_secondsmust be positive and bounded by the server terminal-session TTL. The shared maximum is 3,600 seconds (one hour), and the client must enforce the same value before storing a key.session_refis stored with the authorization so explicit revocation can remove the key later.- The heartbeat JSON boundary must serialize the exact response shape consumed
by the Go client. If the public key remains top-level,
HeartbeatJSON.command_data/1must emitpublic_keyforssh_authorize; otherwise the Go command contract must move the public key intopayload.dataconsistently across server docs, OpenAPI, and client structs. - The dynamic
ssh_authorizepayload is the onlyssh_authorizeshape the server emits. There is no file-compatible fallback and no path the server chooses based on a device capability.
Terminal session sequencing
The browser terminal flow must avoid racing SSH login before the device has stored the dynamic authorization.
Required order for every client:
- Operator requests a browser terminal session.
- Server generates the SSH key pair.
- Server creates the terminal session ref before queueing the command.
- Server queues
ssh_authorizewith the public key, session ref, target user, and TTL. - If command queueing fails, the server clears the terminal session ref and does not expose a browser terminal token.
- Client claims the command on heartbeat, validates the payload, stores the key in memory, and returns an OK command result.
- Only after the OK command result is observed should the UI expose or activate the terminal socket token for the browser.
- If authorization acknowledgement times out or fails, the server clears the terminal session ref and reports a terminal-start failure to the UI.
- The terminal channel consumes the session ref when starting the server-side SSH process.
This is a deliberate change from the current eager token exposure. It prevents a failed early SSH attempt from consuming the server-side terminal session before sshd can authorize the key.
Client-side authorization state
The client maintains an in-memory SSH authorization store keyed by canonical key material/fingerprint and target Unix user. Each entry records:
- canonical public key bytes or stable fingerprint;
- target Unix user;
- command id;
- terminal session ref;
- issued-at timestamp;
- expiry timestamp;
- optional device/session context safe for diagnostics.
The store must:
- reject malformed public keys before storing them;
- deny wrong-user, expired, revoked, unknown, or malformed lookup requests;
- remove entries after expiry;
- support explicit revocation by command id/session ref when available;
- start empty after client restart, making restart invalidate terminal keys.
Local IPC
The client listens on a Unix-domain socket under /run/nixstasis/, for example
/run/nixstasis/ssh-authority.sock. The socket is local-only and must not have a
TCP equivalent.
The IPC protocol uses OpenSSH key material rather than trusting an already-formatted public-key line from sshd.
Request:
{"user":"nixstasis-support","key_type":"ssh-ed25519","key_blob":"AAAA..."}
Allow response:
{"authorized":true,"key_type":"ssh-ed25519","key_blob":"AAAA..."}
Deny response:
{"authorized":false}
The helper prints an authorized key only for an explicit allow response whose canonical key material matches the offered key. Any parse error, timeout, missing socket, invalid response, wrong user, unknown key, or expired key results in no stdout output.
IPC requirements:
- socket path is fixed or configured from trusted local config only;
- helper request size is bounded;
- client response size is bounded;
- helper deadline is short enough not to stall OpenSSH authentication;
- stale sockets are removed only when they are Unix sockets at the expected path;
- permissions allow the client runtime and
nixstasis-ssh-authorityto communicate while denying untrusted local users.
OpenSSH And Accounts Design
New installs configure sshd for support browser terminals with a drop-in equivalent to:
Match User nixstasis-support
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthorizedKeysFile none
AuthorizedKeysCommand /usr/libexec/nixstasis/ssh-authorized-keys %u %t %k
AuthorizedKeysCommandUser nixstasis-ssh-authority
For older OpenSSH distributions that use ChallengeResponseAuthentication
instead of KbdInteractiveAuthentication, packaging may include the compatible
spelling as long as password and keyboard-interactive login are disabled for
nixstasis-support.
Account and privilege rules:
nixstasis-supportis the operator login account for repair workflows.nixstasis-ssh-authorityis a locked system account used only byAuthorizedKeysCommand.- The helper must not be writable by either account.
- The helper must not execute arbitrary client commands or write files based on sshd input.
nixstasis-supportmay keep the documented repair privileges, including sudo and/or run0, but those privileges must not be granted tonixstasis-ssh-authority.
Helper Command Design
Install /usr/libexec/nixstasis/ssh-authorized-keys as a small Go command or
subcommand shipped with the client package. It accepts exactly the OpenSSH
arguments from the configured drop-in:
ssh-authorized-keys <unix-user> <key-type> <base64-key-blob>
Behavior:
- validate argument count, key type, and key blob size before dialing IPC;
- canonicalize the offered OpenSSH key material before lookup;
- send the username, key type, and key blob to the local socket;
- print
<key-type> <base64-key-blob>followed by a newline only when the client explicitly authorizes it; - print nothing for denial or any failure;
- return quickly on timeout;
- log only diagnostics that do not leak private key material. Public-key fingerprints are acceptable; full public keys should be avoided in routine logs.
Server Design
- Continue generating terminal SSH key pairs server-side.
- Continue storing private key material behind an opaque terminal session ref.
- Change the server-side SSH client destination user from
nixstasistonixstasis-supportso it matches the sshdMatch Userblock and dynamic payloadtarget_user. - Authenticate the device SSH server independently of FRP route identity. Devices
report their sshd Ed25519 public host key on authenticated heartbeats (other host
key algorithms are not supported); the first key is
enrolled and each terminal session receives a private temporary
known_hostsfile with strict host-key checking enabled. Changed keys are held pending and require an explicit operator trust action that records the actor, trust time, and previous fingerprint before terminal access resumes. - Queue terminal authorization commands with public key, target user, TTL, and session metadata.
- There is no capability gate. Every authenticated device is dynamic-capable for
terminal authorization, and the in-memory
ssh_authorizepayload is the only shape the server emits. - Gate browser terminal token activation on successful
ssh_authorizecommand acknowledgement. - Clear server-side terminal session refs on queue failure, command failure, authorization timeout, terminal join failure, terminal close, and terminal session expiry.
- Keep terminal close/session cleanup independent from device-side expiry; queue
an
ssh_revokecommand (application/vnd.nixstasis.ssh-revoke+json;version=1) as a best-effort early cleanup signal. Revoke queueing is fire-and-forget: the command is queued fromDeviceLive.Show.clear_*andTerminalChanneljoin-failure / terminate paths, and never blocks terminal cleanup. Clients in turn callsshauth.Store.RevokeSession(session_ref); if the session ref is unknown the operation is a no-op.
Client Design
- Add an internal package for SSH authorization state and IPC server behavior.
- Update the command handler so dynamic
ssh_authorizepayloads add an in-memory authorization via the IPC store. There is no file-based fallback path. - Start the Unix socket server as part of the poll/client runtime lifecycle.
- Ensure client restart starts with an empty authorization store.
- Ensure command-result output reports safe metadata such as target user, session ref, and expiry; it must not report private key material.
- Remove new-install defaults and examples that imply browser terminal SSH keys
should be persisted through
runtime.authorized_keys_path. There is noruntime.authorized_keys_pathruntime config field and no client-side compatibility branch for old server payloads.
Packaging And Deployment Design
Client packaging must:
- create or update
nixstasis-supportfor operator SSH sessions; - create or update locked
nixstasis-ssh-authorityfor OpenSSH helper execution; - install
/usr/libexec/nixstasis/ssh-authorized-keysroot-owned and not writable by service/support/helper users; - install an sshd config drop-in for the support account;
- safely reload or restart sshd when systemd is available;
- preserve existing
authorized_keysfiles on upgrade unless a later design explicitly defines cleanup; - ensure new installs do not depend on any browser-terminal
authorized_keyspath, including/var/lib/nixstasis/.ssh/authorized_keysor/var/lib/nixstasis-support/.ssh/authorized_keys; - ensure
/run/nixstasis/ownership and mode are compatible with the client service user and helper identity only.
Socket permission model:
- create a local system group that both the client runtime and
nixstasis-ssh-authoritycan use for socket access; - create
/run/nixstasiswith mode0750and group ownership limited to that socket-access group; - create
ssh-authority.sockwith mode0660, owned by the client runtime identity and the socket-access group; - verify
nixstasis-ssh-authoritycan connect and an unrelated local user cannot; - prefer systemd
RuntimeDirectory=nixstasiscleanup when the client service is managed by systemd.
Compose/dev-lab support should exercise the installed helper path and sshd configuration rather than bypassing OpenSSH with direct shell access.
Security And Failure Behavior
- Unknown, expired, revoked, malformed, or wrong-user keys are denied by printing no authorized keys.
- If the client process or socket is unavailable, support SSH key auth is denied.
- The IPC socket is local-only and permissioned for the helper/client boundary.
- The helper trusts only its argv and configured socket path; it never follows user-controlled filesystem paths.
- Public-key canonicalization happens before storage and comparison.
- Logs must not expose private keys. Routine logs should prefer command ids, session refs, target users, and public-key fingerprints.
- Restarting the client invalidates all in-memory terminal authorizations.
- Support SSH login remains public-key-only for
nixstasis-support; password and keyboard-interactive paths must not bypass the dynamic key helper.
Migration And Compatibility
- The dynamic
ssh_authorizepath is the only path. There is noruntime.authorized_keys_pathruntime config and no client-side file-based fallback for browser-terminal SSH keys. - New installs prefer
AuthorizedKeysCommandand do not require a persistent browser-terminalauthorized_keysfile. - Server browser-terminal startup does not consult a per-device capability.
Every authenticated device is dynamic-capable; the in-memory
ssh_authorizepayload is the only authorization shape the server emits, and the in-memory store is empty on every client restart, which is the natural invalidation point. - Package upgrades do not need a temporary fallback because there is no legacy
payload shape the server emits. Existing operator-owned
authorized_keysfiles are preserved on upgrade only if a future change reintroduces a file-based path; the current implementation does not touch them.
Documentation Impact
Each reader-facing page has one explicit purpose. Pages marked verify are checked for stale claims but are not changed when their boundary is unaffected.
| Page | Purpose | Ownership |
|---|---|---|
website/docs/client-server-interface.md | Wire shape, acknowledgement, and failure behavior for terminal commands | Update |
website/docs/data-flow.md | Browser-to-device sequence, IPC lookup, and revoke flow | Update |
website/docs/runtime-boundaries.md | Server/client/OpenSSH/FRP authority boundaries | Update |
website/docs/modules/client-command-handler.md | Client command types and dynamic SSH handling | Update |
website/docs/modules/client-transport.md | Heartbeat command fields and result transport | Verify |
website/docs/modules/client-frp-manager.md | Confirm FRP lifecycle is unchanged | Verify |
website/docs/modules/gateway-frp.md | Confirm TCP mux transport is unchanged | Verify |
website/docs/modules/server-devices.md | Session sequencing, target user, and cleanup | Update |
website/docs/modules/deployment-compose.md | Container accounts, socket, and smoke prerequisites | Update |
website/docs/reference/contracts.md | Standalone SSH terminal authorization contract | Update |
website/docs/reference/openapi/device-api.yaml | ssh_authorize/ssh_revoke command schema | Update |
website/docs/features/index.md | Delivered-feature discoverability | Update at close-out |
website/docs/features/in-memory-ssh-authorized-keys/index.md | Implementation evidence and audit history | Create at close-out |
website/docs/SUMMARY.md | mdBook navigation for design and delivered record | Update at close-out |
packages/client/README.md | Native client installation and support-account behavior | Update |
deploy/compose/README.md | Compose SSH account and operator flow | Update |
packages/server/README.md | Confirm no feature-specific server contract is documented there | Verify |
website/docs/planned-features.md | Roadmap status and scope | Update as implementation status changes |
Product documentation must stand alone; readers must not need to open the feature design to obtain the command JSON, helper argv, IPC request/response, socket permissions, account rules, or fail-closed behavior.
Validation Plan
- Unit tests for the client in-memory authorization store: add, match, expiry, revoke, restart-empty initialization, malformed-key rejection, and wrong-user denial. Include a deterministic expiry cleanup assertion without requiring a later lookup.
- Unit tests for the helper with a fake Unix socket: allow, deny, timeout, malformed argv input, invalid response, missing socket, wrong OpenSSH token shape, oversized payload, and canonical allow-response mismatch handling.
- Unit tests for the IPC server request/response behavior, stale socket cleanup, and socket permission checks where practical.
- Client command-handler tests proving dynamic
ssh_authorizestores keys in memory, validates the exact versioned payload and session binding, enforces the shared TTL maximum, handlesssh_revoke, and has no file-based fallback. - Server tests proving heartbeat command serialization includes the exact dynamic
ssh_authorizeJSON shape, versioned content type, TTL/session fields, target user, and top-level public-key handling. Testssh_revokepayloads as well. - Server tests proving terminal startup creates session refs before queueing,
exposes the terminal token only after the matching OK result, rejects omitted
or unrelated command IDs, cleans up on queue/ack/offline/join/close/expiry
failure, and uses
nixstasis-supportas the SSH destination user. - Packaging tests or scripted checks for every native installer and the container:
helper path, sshd drop-in, support user, locked authority user, socket group,
runtime directory/socket modes, sudo/run0 separation, safe sshd reload, and
public-key-only support login. Existing operator-owned files are preserved and
no browser-terminal key is written to an
authorized_keysfile. - Host real-sshd integration where
AuthorizedKeysCommandinvokes the helper with%u %t %k, authenticates a short-lived key, and denies unknown/expired/ wrong-user/revoked keys. This focused coverage and the native packaging checks require Linux; macOS is outside the supported runtime and packaging contract. - Compose dev-lab/browser smoke that launches the actual deployed terminal,
verifies
whoamireturnsnixstasis-support, runs a safe diagnostic, closes the session, and verifies later expiry/revocation denial. The wrapper must not claim host real-sshd coverage as Compose/browser coverage; unavailable lab prerequisites are recorded explicitly by validation. - Documentation validation for all changed mdBook pages and OpenAPI schema checks for the device command contract.
Reconciliation Bookends
- Before implementation, read package-level agent instructions for client/server changes.
- Before implementation, reconcile that this feature spec was seeded from a
planned feature entry that may not exist on the selected base branch; add or
update the
website/docs/planned-features.mdentry before marking implementation complete. - During implementation, keep server command payloads, client command handling, packaging, OpenAPI, and docs aligned in the same unit of work.
- Before completion, search code and docs for stale file-based browser-terminal-key guidance and reconcile it with the dynamic auth rollout story.
- Before completion, verify new-install defaults no longer configure any
browser-terminal persistent
authorized_keyspath as the normal path.
Metadata
- Feature slug:
in-memory-ssh-authorized-keys
Feature Summary
Replace persistent browser-terminal key files with an OpenSSH AuthorizedKeysCommand helper backed by the Go client's
in-memory, TTL-bound authorization store and local Unix socket.
User Intent
Operators need privileged support sessions without making the Nixstasis service account a login identity or persisting ephemeral browser-terminal keys on managed devices.
User-Facing Behavior
Authorized terminal sessions target nixstasis-support; unknown, expired, revoked, malformed, and wrong-user keys fail
closed. Client restart clears all ephemeral authorizations.
Requirements
The detailed runtime, helper, server, client, packaging, security, and compatibility requirements above remain authoritative.
Proposed Design
The implementation uses the helper, Unix socket, in-memory store, dynamic server payload, OpenSSH account configuration, and packaging design documented above without reintroducing file-based fallback.
Existing Context
Dynamic command delivery, FRP TCP muxing, the dedicated support account, systemd packaging, and the completed Go client runtime are reused. The implemented code already uses the dynamic payload exclusively.
Architecture Consistency
The server authorizes operators and issues short-lived keys; the client owns device-local authorization state; OpenSSH owns login enforcement; FRP only transports the connection. No boundary grants authority to another.
Operational Considerations
Packaging must install the helper, authority user, socket permissions, sshd drop-in, support privileges, and safe reload behavior. Missing client or IPC state denies access rather than falling back to files.
Specification Review Reconciliation
The initial implementation evidence exposed clear contract gaps without changing the product intent. The authoritative design therefore retains these decisions:
- Terminal socket-token exposure and channel startup require the matching
ssh_authorizecommand result, not merely a queued command or arbitrary command ID. - Every post-creation failure clears server private-key material and issues an idempotent best-effort device revoke; authorization timeout stops browser retries.
nixstasis-supportis the only dynamic terminal target. Configured SSH users cannot override that account, and the authorization TTL cannot exceed the terminal-session lifetime.- Both server and client require the exact versioned payload content type,
payload.name == session_ref, and canonical key/allow-response agreement. - The installed client and helper use the fixed trusted socket path
/run/nixstasis/ssh-authority.sock; the helper does not load general client configuration or accept an untrusted socket path. - Native installers are first-class supported deployment paths and must provision the same authority user, socket group, runtime directory, helper permissions, and safe sshd reload behavior as the container image.
- The host real-sshd test and Compose/browser smoke are separate evidence classes; neither is reported as the other.
- The exact SSH command/IPC contract belongs in reader-facing reference docs and
OpenAPI, including
ssh_revoke; the feature design remains planning authority.
Validation Strategy
Run focused client store, helper, IPC, command-handler, real-sshd, server payload, terminal lifecycle, packaging-contract, Compose/browser, and documentation checks listed in the validation plan above.
Implementation Decomposition
The imported implementation history remains beneath nixstasis-bdv.7 as
provenance. The corrective work is divided into the following bounded slices:
.7.74: terminal acknowledgement gate and command/session binding..7.75: exception-safe terminal key cleanup and revoke lifecycle..7.76: native SSH authority packaging and installer verification..7.77: dynamic SSH payload, key, TTL, and helper invariants..7.78: fixed trusted client/helper IPC socket configuration..7.79: reader-facing SSH command contracts, OpenAPI, and documentation..7.80: actual Compose/browser terminal smoke acceptance..7.65-.7.67and.7.70-.7.71: focused validation and stale-reference checks, gated on the relevant corrective children.
Each corrective child depended on .6 and owned its implementation, tests, and
bounded commit.
Dependencies and Parallelism
.7.74, .7.75, .7.76, .7.77, and .7.78 proceeded independently after
spec-reconcile, with server, client, and packaging ownership kept separate.
.7.79 followed the stale-reference search .7.71; documentation validation
.7.70 followed .7.79. Focused client tests .7.65 followed .7.77-.78,
server tests .7.66 followed .7.74-.75-.77, package verification .7.67
followed .7.76, and Compose/browser smoke .7.80 followed the terminal,
packaging, client-contract, and socket corrections.
Risks and Tradeoffs
Authentication now depends on the client process and IPC socket, so outages fail closed. In-memory state improves key hygiene but intentionally invalidates sessions on restart.
Rejected Alternatives
Persistent authorized_keys, broad service-account sudo, TCP IPC, password login, and a custom shell daemon remain
rejected.
Open Questions
No product or validation question remains open.
Deferred Decisions
Permanent operator keys and broader privileged-command frameworks remain out of scope.