Skip to main content

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 AuthorizedKeysCommand instead of persistent browser-terminal key files.
  • Keep ephemeral terminal public keys in client memory only.
  • Migrate browser terminal SSH login from the current nixstasis account to the dedicated nixstasis-support repair account.
  • Run the OpenSSH helper as a separate locked-down nixstasis-ssh-authority identity, not as nixstasis-support and 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_authorize payload is the only terminal authorization shape, and the client writes the offered key into its in-memory store via a Unix-domain IPC query from AuthorizedKeysCommand.
  • 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 nixstasis service account or the dynamic-key helper.
  • Deleting legacy operator-owned authorized_keys files 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_authorize before 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_key for queued command maps unless that boundary is updated.
  • The Go client handles ssh_authorize by resolving an allowed authorized_keys path and appending the provided public key to that file.
  • The packaged default currently includes runtime.authorized_keys_path under /var/lib/nixstasis/.ssh/authorized_keys.
  • The server-side SSH client currently connects as nixstasis@atom-<device>-ssh through 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_key is the full authorized-keys-style public key generated by the server.
  • target_user is required and must be nixstasis-support.
  • ttl_seconds must 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_ref is 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/1 must emit public_key for ssh_authorize; otherwise the Go command contract must move the public key into payload.data consistently across server docs, OpenAPI, and client structs.
  • The dynamic ssh_authorize payload is the only ssh_authorize shape 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:

  1. Operator requests a browser terminal session.
  2. Server generates the SSH key pair.
  3. Server creates the terminal session ref before queueing the command.
  4. Server queues ssh_authorize with the public key, session ref, target user, and TTL.
  5. If command queueing fails, the server clears the terminal session ref and does not expose a browser terminal token.
  6. Client claims the command on heartbeat, validates the payload, stores the key in memory, and returns an OK command result.
  7. Only after the OK command result is observed should the UI expose or activate the terminal socket token for the browser.
  8. If authorization acknowledgement times out or fails, the server clears the terminal session ref and reports a terminal-start failure to the UI.
  9. 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-authority to 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-support is the operator login account for repair workflows.
  • nixstasis-ssh-authority is a locked system account used only by AuthorizedKeysCommand.
  • 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-support may keep the documented repair privileges, including sudo and/or run0, but those privileges must not be granted to nixstasis-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 nixstasis to nixstasis-support so it matches the sshd Match User block and dynamic payload target_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_hosts file 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_authorize payload is the only shape the server emits.
  • Gate browser terminal token activation on successful ssh_authorize command 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_revoke command (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 from DeviceLive.Show.clear_* and TerminalChannel join-failure / terminate paths, and never blocks terminal cleanup. Clients in turn call sshauth.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_authorize payloads 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 no runtime.authorized_keys_path runtime config field and no client-side compatibility branch for old server payloads.

Packaging And Deployment Design​

Client packaging must:

  • create or update nixstasis-support for operator SSH sessions;
  • create or update locked nixstasis-ssh-authority for OpenSSH helper execution;
  • install /usr/libexec/nixstasis/ssh-authorized-keys root-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_keys files on upgrade unless a later design explicitly defines cleanup;
  • ensure new installs do not depend on any browser-terminal authorized_keys path, including /var/lib/nixstasis/.ssh/authorized_keys or /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-authority can use for socket access;
  • create /run/nixstasis with mode 0750 and group ownership limited to that socket-access group;
  • create ssh-authority.sock with mode 0660, owned by the client runtime identity and the socket-access group;
  • verify nixstasis-ssh-authority can connect and an unrelated local user cannot;
  • prefer systemd RuntimeDirectory=nixstasis cleanup 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_authorize path is the only path. There is no runtime.authorized_keys_path runtime config and no client-side file-based fallback for browser-terminal SSH keys.
  • New installs prefer AuthorizedKeysCommand and do not require a persistent browser-terminal authorized_keys file.
  • Server browser-terminal startup does not consult a per-device capability. Every authenticated device is dynamic-capable; the in-memory ssh_authorize payload 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_keys files 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.

PagePurposeOwnership
website/docs/client-server-interface.mdWire shape, acknowledgement, and failure behavior for terminal commandsUpdate
website/docs/data-flow.mdBrowser-to-device sequence, IPC lookup, and revoke flowUpdate
website/docs/runtime-boundaries.mdServer/client/OpenSSH/FRP authority boundariesUpdate
website/docs/modules/client-command-handler.mdClient command types and dynamic SSH handlingUpdate
website/docs/modules/client-transport.mdHeartbeat command fields and result transportVerify
website/docs/modules/client-frp-manager.mdConfirm FRP lifecycle is unchangedVerify
website/docs/modules/gateway-frp.mdConfirm TCP mux transport is unchangedVerify
website/docs/modules/server-devices.mdSession sequencing, target user, and cleanupUpdate
website/docs/modules/deployment-compose.mdContainer accounts, socket, and smoke prerequisitesUpdate
website/docs/reference/contracts.mdStandalone SSH terminal authorization contractUpdate
website/docs/reference/openapi/device-api.yamlssh_authorize/ssh_revoke command schemaUpdate
website/docs/features/index.mdDelivered-feature discoverabilityUpdate at close-out
website/docs/features/in-memory-ssh-authorized-keys/index.mdImplementation evidence and audit historyCreate at close-out
website/docs/SUMMARY.mdmdBook navigation for design and delivered recordUpdate at close-out
packages/client/README.mdNative client installation and support-account behaviorUpdate
deploy/compose/README.mdCompose SSH account and operator flowUpdate
packages/server/README.mdConfirm no feature-specific server contract is documented thereVerify
website/docs/planned-features.mdRoadmap status and scopeUpdate 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_authorize stores keys in memory, validates the exact versioned payload and session binding, enforces the shared TTL maximum, handles ssh_revoke, and has no file-based fallback.
  • Server tests proving heartbeat command serialization includes the exact dynamic ssh_authorize JSON shape, versioned content type, TTL/session fields, target user, and top-level public-key handling. Test ssh_revoke payloads 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-support as 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_keys file.
  • Host real-sshd integration where AuthorizedKeysCommand invokes 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 whoami returns nixstasis-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.md entry 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_keys path 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_authorize command 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-support is 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.67 and .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.