Skip to content

Decisions

The record of what ssoossh deliberately does not do, and why. If you are about to ask “why doesn’t it just…”, the answer is probably here. Decisions are re-openable – but with a reason the circumstances changed, not by re-asking.

What ssoossh does do is in How it works. The operator-facing summary of the security properties these decisions produce is the security model.

Hard constraints every change is measured against, from the original design brief:

  • The server never receives private keys. The client sends private keys nowhere except the local ssh-agent or a local file.
  • The client never opens a listening port. No loopback redirect; the browser lands on the server and the client learns the outcome over its SSE stream.
  • Server config is the outer bound on every option. Client request asks, web UI narrows or adjusts, server config gates. Options the deployment does not permit are trimmed (not rejected) and shown in the web UI before approval.
  • Group membership never appears in a certificate. Groups feed the lifetime decision only.
  • verify-required is not used at all. no-touch-required is not offered for client-generated keys, only relevant for enrolled sk- keys on the service path.

Worth doing eventually; not started because nothing needs it yet.

  • Tighter rate limiting on service-enrollment redemption. The retrieve endpoint has a per-code rate limit; a stricter scheme may be warranted once service enrollment sees real unattended use.
  • A dedicated signer configuration file. The signer process reuses the full server config with mode-aware validation. That means secrets the signer never uses can sit in its config file – acceptable for now, worth hardening once the split topology is in real use.
  • Re-checking group claims mid-session. Groups are read at login and the session’s absolute cap bounds how stale they can get. If a shorter revocation window is ever needed, the fix is re-validating claims on session refresh – not caching group state in the database, which drifts from the identity provider. The opt-in ldap.authoritative_role_groups is not that cache: it lets the directory remove a role group the claim carries, between logins, and nothing stored can grant one.
  • API versioning, gateways, service meshes, circuit breakers. One self-hosted binary with a single first-party client under active development. There is nothing to version against and nothing to mesh.
  • Internationalization. Single-locale self-hosted tool. Revisit when the community asks for it.
  • Certificates that outlive their approval. Issued certificates are never persisted server-side. Delivery is the only copy; a client that misses it re-requests. This is a feature: there is no certificate store to steal.
  • Host certificates. A host certificate asserts “this key speaks for this hostname,” but nothing in the design could verify that claim: approval reduced to a human eyeballing a hostname string an unauthenticated requester typed, and every scheme for server-side principal mappings had the same hole one layer down. Issuing unverifiable host identity from the CA that also signs user access is worse than issuing none. Revisit only with a real host-verification mechanism – something like an ACME challenge proving control of the name – not by re-arguing the approval flow. Local principal mapping for AuthorizedPrincipalsCommand stayed in the client (host mapping, host principals); it never needed a server.
  • An admin role stored in the database. Admin is an OIDC group named in configuration. A database flag has a bootstrapping problem (“who creates the first admin”), drifts from the identity provider, and lets an admin promote others through the API. Nothing reachable over HTTP can widen anyone’s authority – that property is the point.
  • Runtime-editable policy that can loosen anything. Policy edited through the web tier may only ever narrow. A compromised web tier can therefore deny service, but cannot obtain a certificate the config file would not already have allowed.
  • Encrypting signing-job payloads to authenticate the sender. Encryption does not authenticate, the payload is not secret (it is the future certificate’s own contents), and a fleet-shared key is coarse and awkward to rotate. The broker connection carries per-node identity via mTLS instead. Additionally the certificate itself is useless without the private key that never left the client.
  • Pinning user certificates to a source address. People move – office, VPN, hotel, phone tether – and a pinned certificate turns every network change into a failed login for no gain a short lifetime does not already provide. Services sit still, so service certificates can be pinned. This may be implemented at some point as yes, users do log into remote systems and need to ssh from them. So, pinning those certificates could be considered a worthwhile choice.

Declined: durability and availability machinery

Section titled “Declined: durability and availability machinery”
  • JetStream (durable delivery) for the signing pipeline. NATS core with queue groups, at-most-once. A dropped signing job means the person who just approved sees their terminal still waiting, cancels, and reruns login – the flow is short and interactive, so the human is the retry mechanism, and durability machinery buys nothing worth its complexity.
  • Sticky sessions as a multi-instance workaround. Request-ID routing would fix certificate delivery but not the stranded-request sweep or per-process session keys – a configuration that half-works is worse than a clear requirement. Multi-instance requires NATS, full stop.
  • A “0 means expiry is disabled” request timeout. Every consumer would need a special case for it and each is a hazard – a sweep with no bound, a cache with no safe eviction age. Startup rejects a non-positive cert_options.client_timeout instead.
  • Separate knobs for the approval window and the signing grace. One budget, cert_options.client_timeout, with both shares derived from it. Two knobs could not say which phase each bounded, and the number an operator actually cares about – how long a client can hang – was neither of them but their sum, counting the signing share twice because the sweep interval derives from it as well.

The NSS directory serves POSIX users and groups to hosts through systemd userdb: nss-systemd, which systemd ships, is the module inside every caller, and ssoossh-nss is a userdb service. These were considered and declined.

A libnss_ssoossh.so.2, in C, beside a Go daemon over a private socket. It puts our code inside every setuid caller (sudo, su, passwd, sshd), needs a private protocol to version and a second build toolchain, and still needs the daemon for the cache and the key. systemd userdb gives the same result with no code of ours in callers. The price is the platform floor (systemd 245, glibc) and that “service not available” reaches callers as “not found”.

Reopened for RHEL 8 (2026-10-21, issue #217): that price – the systemd 245 floor – shut out the RHEL 8 family, which has no userdb at all and runs to 2029. There, narrowly, a module of our own does ship: libnss_ssoossh. It is not the declined design above: no private protocol (it speaks systemd’s own stable io.systemd.UserDatabase varlink, the same the daemon already serves), and the daemon it talks to runs standalone and binds the socket itself since there is no systemd-userdbd to activate it. The decline still stands for systemd 245+, where stock nss-systemd is that client and no module of ours belongs in callers.

A module that calls the server itself, as some cloud OS-login modules do. The fleet key would have to be readable by every process that resolves a user. Unprivileged callers cannot write a cache the next one could trust, so there is no offline mode. And sandboxed callers (RestrictAddressFamilies=, PrivateNetwork=, SELinux domains allowed only NSS sockets) and early boot have no network to call with.

sssd with id_provider = proxy loading an HTTP module. The proxy provider calls the module synchronously inside sssd’s backend, so one slow fetch stalls every lookup; there is no refresh at login, so a removed sudo group waits for the sync plus the cache lifetime; and it means sssd on every Debian and Ubuntu host. Hosts that can reach the directory directly are better served by sssd pointed straight at it; the NSS directory exists for hosts that cannot.

Reading an entry only when a host first asks for it, instead of syncing everything the filters select. A group’s member list would then hold only the members the server happened to have read, which is wrong for getent group and anything deciding on it. Fetching a found group’s members one level deep fixes the list but adds a second kind of row whose own groups are never read. With the full sync, nss.user_filter and nss.group_filter are the control over what the server stores. An unknown name is still read on demand, so a new account resolves before the next sync.