Security model
What ssoossh guarantees, what it deliberately does not defend against, and where each control lives in the config file. Written for whoever has to justify the deployment: an operator, a reviewer, or the person asked “what happens if the server gets popped”.
The short version: the config file on disk is the outer bound on everything. Nothing reachable over HTTP – not a client, not the web UI, not an admin, not an attacker holding the web tier – can produce a certificate the loaded configuration would not already have allowed.
Trust boundaries
Section titled “Trust boundaries”flowchart LR
accTitle: Trust boundaries between client, browser, web tier, signer and host
accDescr {
Five separated areas. On the user’s machine the ssoossh client holds the
private key, in an agent or in a 0600 file, and sends only the public
key to the web tier. The browser holds an OIDC session cookie and is
what approves. The web tier — ssoosshd serve api — runs the HTTP API and
web UI against a database and an audit log, and hands the signer a
signing job containing no secrets. The signer, ssoosshd sign, holds the
CA private key and has no database of its own; it returns the signed
certificate, which the web tier delivers to the client over server-sent
events. On the target host, sshd trusts the CA through TrustedUserCAKeys
and pam_ssoossh reads the root-owned principals map; the user’s private
key never leaves their machine.
}
subgraph U["User's machine"]
CLI["ssoossh client"]
PK["private key: agent or 0600 file"]
end
subgraph BR["Browser"]
SESS["OIDC session cookie"]
end
subgraph WEB["Web tier: ssoosshd serve api"]
API["HTTP API and web UI"]
DB[("database")]
AUD["audit log"]
end
subgraph SGN["Signer: ssoosshd sign"]
SIGNER["signer, no database"]
CA[["CA private key"]]
end
subgraph HOST["Target host"]
SSHD["sshd, TrustedUserCAKeys"]
PAM["pam_ssoossh"]
MAP["principals-map, root-owned"]
end
PK --- CLI
CLI -->|"public key only"| API
SESS -->|"approve"| API
API -->|"signing job, no secrets"| SIGNER
CA --- SIGNER
SIGNER -->|"signed certificate"| API
API -->|"certificate over SSE"| CLI
API --- DB
API --- AUD
CLI -->|"certificate, key stays local"| SSHD
PAM -->|"ephemeral public key"| API
MAP --- PAM
MAP --- SSHD
Four boundaries are worth naming:
- The user’s machine keeps the private key. Only a public key crosses to the server, on every path including PAM.
- The web tier holds no CA key when you run the split topology. It holds the database, the sessions, and the audit log.
- The signer holds the CA key and no database. It never learns who requested a certificate; the signing job is the future certificate’s own contents and nothing more.
- The target host decides which local account a certificate reaches. That decision is a root-owned file on the host, not a server setting.
The invariants
Section titled “The invariants”Issuance
Section titled “Issuance”Server config is the outer bound. Requests ask, the web UI narrows, the config file gates. Options a deployment does not permit are trimmed rather than rejected, so the approval page can show what was asked for next to what would actually be granted – struck through where policy took something away. A human authorizing issuance sees what they are authorizing.
Standing policy sits in the same shape, with the administrator in the “narrows” position: policy may only ever reduce what the config file already allows. See Certificate lifetime policy.
Every issuance is approved by a human in the browser, then signed. There is no path that turns a request into a certificate without passing through the sign queue.
Two option families are never granted from a request. ForceCommand and
SourceAddresses are dropped outright, because there is no configured
ceiling to bound them against, and granting an unbounded client-requested
critical option would break the rule above. no-touch-required is granted
only for service certificates, and only for an enrolled hardware-backed sk-
key – never for a client-generated one. verify-required is never used at
all.
There is no host certificate type. Nothing in the design can verify a
host’s claim to its own hostname, so approval would reduce to a human
eyeballing a string an unauthenticated requester typed. Unverifiable host
identity from the CA that also signs user access is worse than none. The
client’s host mapping and host principals commands are local tooling for
AuthorizedPrincipalsCommand and never talk to the server.
Private keys never leave the machine that generated them. The server
never generates or stores a private key; it records public key material and
issuance metadata only. On macOS and Linux a key file is written 0600; on
Windows the client sets an equivalent access list.
Issued certificates are never persisted. Delivery to the waiting client is the only copy. A client that misses the window re-requests, which is cheap because certificates are short-lived by design. There is no certificate store to steal.
The CA key is reached through one interface, not hardcoded to a backend. That seam is what lets the signer become a separate, minimally-privileged process.
Identity
Section titled “Identity”Group membership never appears in a certificate. Groups feed the lifetime and gating decisions only.
Persisted group rows never grant anything. Admin, SOC, auditor, fleet
key admin, and the certificate policy gates are all evaluated from the session
identity. The user_groups table is a snapshot for notification fan-out and
display: it answers “who should this reach”, never “may this caller do this”.
The one exception narrows and is opt-in: with
ldap.authoritative_role_groups
on, a role group (admin, SOC, auditor, fleet key admin) counts for a
directory-linked user only while the latest directory read still lists it, so
the sync can take a role away between logins, effective on the next request.
The directory never grants a role.
Session and authorization headers are never captured. The headers
recorded on an approval decision are a deliberate allowlist – User-Agent,
Accept-Language, X-Forwarded-For – not “every header minus a denylist”.
Cookie carries the live session token; neither it nor Authorization is
ever written to an audit record.
Client-supplied addresses never feed policy. The address any policy consults is the one the server observed, not one a caller reported. Same for everything a PAM or console request says about its host: see host context.
The code-level statement of all of the above, with the reasons each one is load-bearing, is Invariants.
Where the CA private key lives
Section titled “Where the CA private key lives”Exactly one of four sources, and startup fails without one:
| Source | Configured by | What the process holds |
|---|---|---|
| Inline PEM in the config file | ssh_key |
the private key, in memory, readable by anyone who can read the config file |
| A file beside the config | ssh_key_file |
the private key, in memory, readable by anyone who can read that file |
An ssh-agent |
ssh_key_agent |
nothing. The signer sends blobs to the agent and never sees the key |
| A PKCS#11 token (HSM or SoftHSM2) | hsm.module and the rest of the hsm section |
a handle. The private key never leaves the hardware |
The agent is the one that changes the trust model
Section titled “The agent is the one that changes the trust model”ssh_key_agent is the only source where
the private key is neither in ssoosshd’s memory nor reachable from it. The
agent protocol has no export operation, so a compromise of the server is a
signing oracle, not a key disclosure.
An agent can hold a PKCS#11 token (ssh-add -s <module>), so this is
usually the better way to reach an HSM than hsm is: the vendor’s library
loads in the agent’s address space rather than the signer’s, and the default
ssoosshd build needs no PKCS#11 support at all.
What it does not give is least authority. The agent signs whatever it is handed, so a compromised signer can still issue any certificate the CA could. That bound comes from split mode, below, and the two compose.
What ssh_key_file changes, and what it does not
Section titled “What ssh_key_file changes, and what it does not”ssh_key_file moves the
key out of the config file into a file of its own. That is worth doing –
the config file can then be world-readable, rendered from a template, or
committed to configuration management without carrying the CA with it, and
the key file gets ownership and a mode chosen for a private key rather than
for a config file.
It does not change the trust model. The signer still reads the key into memory, so the process holds the whole private key exactly as it does with an inline one. It moves which file is the CA; it does not stop there being one.
What a passphrase changes, and what it does not
Section titled “What a passphrase changes, and what it does not”ssh_key_passphrase_file
decrypts a passphrase-protected key at startup. It protects the key at
rest and nothing else: after parsing, the private key is plaintext in the
signer’s memory whether or not it was encrypted on disk.
The threat it actually addresses is the key file escaping on its own – in a
backup, a volume snapshot, a support bundle, a stray scp. That is a real
threat and flat PEM has no answer to it.
The same warning applies to
hsm.pin_file next to a SoftHSM2
token directory, for the same reason.
Independently of which source you choose, the key does not have to sit in the
web tier at all. Run ssoosshd sign as its own process against
pubsub.backend nats and the
CA key never shares memory with HTTP handling, OIDC and LDAP calls, or the
web UI. In that mode signers announce their public keys and the web tier
serves only those, so the web tier holds no private key of any kind. Several
CA keys can be active at once, which is what covers rotation. See
startup modes and
HSM and PKCS#11.
A compromised web tier, or a rogue admin
Section titled “A compromised web tier, or a rogue admin”Neither can widen access. This is a design property, not an operational hope.
| An attacker holding the web tier can | …but cannot |
|---|---|
| Deny service: refuse approvals, drop requests, break logins | Issue a certificate more permissive than the config file allows |
| Read the database: request history, audit rows, public keys, issuance metadata | Recover any private key, or any issued certificate |
| Edit standing policy through the web tier | Loosen anything by doing so – runtime-editable policy may only narrow |
| See what was approved | Sign anything, in the split topology, because it holds no CA key |
An admin is subject to the same bound plus its own limits. Admin is an OIDC
group named in
admin.require_group, not a
database flag: there is no bootstrapping question about who creates the first
admin, no drift from the identity provider, and no API path by which an admin
promotes anyone. On top of that, an admin cannot approve someone else’s
request, raise a ceiling, grant admin, or touch the audit trail. The roles
fail closed – an empty group authorizes nobody – and admins inherit the
auditor views rather than the reverse
(admin.auditor_group,
admin.soc_group).
The auditor’s effective-configuration view is a fixed, deliberately chosen set of fields. It never shows the whole file, and never the CA key, client secret, cookie signing key, or database password.
Why there is no revocation
Section titled “Why there is no revocation”Deliberately absent, and not an oversight. Certificates are short-lived by design, so they expire faster than a revocation list could reasonably be distributed to every host. Expiry does the work.
The practical consequence for offboarding: disable the person in the identity
provider. They cannot get a new certificate, and the one they hold dies on its
own within the type’s
valid_duration.
Nothing on your hosts needs touching – no KRL to distribute, no
authorized_keys to edit. Ceilings that bound how long “short-lived” can be
configured to mean are
max_cert_lifetime
and
max_service_cert_lifetime.
The one thing to hold onto is that a certificate is checked once, at session start. An established SSH session does not drop when the certificate behind it expires.
Consent phishing, and the console code
Section titled “Consent phishing, and the console code”Request binding stops one user approving another user’s pending request. It does nothing about the other shape of the attack: a user talked into approving a request the attacker created for them. “Click this link and sign in” is a phone call away.
The console flow’s eight-character code (Crockford Base32, shown as
K7M4-QP2X) is the control for that, not a convenience.
Which side is which matters here. The certificate is delivered to the machine that asked for it and carries the approver’s identity, so the attacker is the one at the console and the victim is the one with the browser. The attacker’s script is not “read me a code” but “I am at the console of db07, go and approve K7M4-QP2X” – a call they can only make while sitting at a real console, to a person who then has to sign in, find the page and type it.
Three properties make it hold:
- Resolving a code requires a session. An unauthenticated caller can never turn a code into a request ID, and the request ID is what the certificate is delivered against.
- Resolving claims the request. A second person typing the same code is refused.
- The approval window is the attacker’s working time, so console login
gets its own, shorter budget:
cert_options.console.client_timeoutdefaults to2magainst the global5mceiling incert_options.client_timeout. A type may only shorten the global budget, never extend it.
Two more controls sit on the same path.
cert_options.console.allowed_networks
refuses a request from outside named networks at creation, before a keypair is
certified and before any human is asked – gated on the address the server
observed, not on a hostname the caller typed. And which accounts may console
into which machine stays the host’s decision: put
pam_succeed_if.so user ingroup ... above the ssoossh line in
/etc/pam.d/login. Host-side, root-owned, and it fails before any network
call. A group sent on the wire would be untrusted input that stops applying
the moment someone omits it.
See Console login for the whole flow.
The NSS fleet key
Section titled “The NSS fleet key”The NSS directory answers hosts’ user and group lookups, and every lookup carries a fleet API key. Its exposure is that of an LDAP directory with a shared bind account, the usual sssd deployment, not of an anonymous bind:
- Any host holding a key can ask for any name the directory sync stored.
There is no per-host identity and no per-key group gate on the server;
narrowing is the host’s own
require_groups. The control over what can be learnt isnss.user_filter, which decides what is stored at all. - The key is shared. It is baked into an image, not provisioned per host,
and readable by root only. Whoever holds root on one host holds the key for
the fleet. Scope a key to an environment, give it a managing group, rotate
it, and watch
nss.key_source_alertand the miss-rate alert for a key in the wrong hands;nss.source_allow_listlimits where lookups may come from at all. - A key reads; it grants nothing. The answer is POSIX identity, never a login decision, and nothing on the lookup path can issue or widen a certificate.
- The secret is never logged. The server stores only its SHA-256, takes
the
X-Ssoossh-Key-Secretheader off every request before the access log writes it, and records only the key id in logs and audit events.
On the host the trust root is a pinned CA or public key, never the system store, so a host cannot be pointed at another server by DNS or a proxy variable.
Threats and controls
Section titled “Threats and controls”| Threat | Control | Where configured |
|---|---|---|
| A user’s private key leaks | Short lifetimes; expiry replaces revocation | cert_options.user.valid_duration, max_cert_lifetime |
| The server is asked for more than policy allows | Config is the outer bound; options trimmed, not rejected, and shown before approval | the whole cert_options section |
| The web tier is compromised | It holds no CA key in split mode; runtime policy may only narrow | pubsub.backend nats plus a ssoosshd sign process |
| The CA key is read off disk | PKCS#11 token: the private key never leaves the hardware | hsm instead of ssh_key |
| A rogue admin escalates | Admin is an OIDC group, not a row; cannot self-approve, raise a ceiling, grant admin, or edit audit | admin.require_group |
| The database is stolen | No private keys, no issued certificates, no group rows used for authorization | not configurable, by design |
| Consent phishing on a console login | A code that exists only on the console screen; short per-type budget | cert_options.console.client_timeout |
| A console request from an unexpected network | Refused at creation on the observed address | cert_options.console.allowed_networks |
| One user approves another’s request | Request binding, and resolving a code claims the request | not configurable, by design |
| A PAM caller lies about its host, user, or command line | Nothing in the host context feeds a decision; principals come from the approver’s held accounts | not configurable, by design (host context) |
| A local account is assumed without authority | Check 3 in the module: the host’s principals-map decides which of the approver’s names may become which local account |
the host’s root-owned map, plus pam_succeed_if above the ssoossh line |
| An enrollment code is stolen | The code is bound to the stored public key, so it cannot be paired with an attacker’s keypair; every redemption is logged and notified to the account’s holders | cert_options.service, email notifications |
| A browser session is stolen or replayed | Signed, encrypted, strict same-site cookies; sliding idle window under an absolute cap activity never extends; CSRF and per-request CSP nonces | http.cookie_key, http.cookie_idle_timeout, http.cookie_max_age |
| A proxy hop spoofs the client address | X-Forwarded-For is ignored unless the hop is named; empty by default |
http.trusted_proxies |
| The OIDC redirect is pointed somewhere else | Redirect URI and the CSRF origin check both derive from one setting | http.public_url |
| Brute-forcing a console code | Rate limit keyed on both the submitting session and the source address | http.console_code_rate_limit.limit |
| Group membership goes stale mid-session | Groups are read at login; the session’s absolute cap bounds staleness; with a directory, the sync can take a role group away on the next request | http.cookie_max_age, ldap.authoritative_role_groups |
| An issued certificate cannot be traced later | Append-only decision records, and sshd logs the key ID on every login |
audit.retention, key ID templates |
| A signing job is intercepted on the broker | Per-node identity via mTLS; the payload is not secret and is useless without the client’s private key | multi-instance and NATS |
| A host is made to believe a name has another uid | Pinned TLS to ssoosshd; local names and ids always win; min_id keeps system ids out; a uid or gid never moves without an admin’s accept; two entries claiming one number or name are both withheld |
NSS directory, the host’s nss.yaml |
| A fleet key leaks | It reads identity only; per-key alerts on address count and misses; rotation with a report of who has switched; revoke | nss.key_source_alert, nss.source_allow_list |
| A fleet key holder spoofs its source address | The NSS allow list, the audited source_ip and the per-key rate-limit bucket all use the server-observed address, which honours X-Forwarded-For only from named hops; an overbroad entry lets the holder pass the allow list, forge the audit and get a fresh bucket per address, and nss.key_source_alert warns but never blocks |
http.trusted_proxies, empty or only the real proxies (NSS rate limits) |
| A local user hides directory groups from a setuid program | Grant by directory group, never deny by one | sudoers, and any other setuid program’s rules, on the host |
| Clock skew breaks PAM validity checks | The module’s own skew tolerance, and run NTP on every host | the skew-tolerance module argument in the host’s pam.d stack |
What is deliberately out of scope
Section titled “What is deliberately out of scope”- Revocation, for the reason above.
- User certificates pinned to a source address. People move between office, VPN, hotel and tether, and a pinned certificate turns every network change into a failed login for no gain a short lifetime does not already provide. Service certificates sit still and can be pinned.
- At-least-once delivery of signing jobs. The flow is short and interactive, so the human is the retry mechanism: a dropped job means a terminal still waiting, cancelled and rerun.
- Re-checking group claims mid-session. The session’s absolute cap bounds how stale a claim can get. The identity provider is never asked again; the one mid-session change is the opt-in directory narrowing of role groups above, which can only remove.
- A dedicated signer config file. The signer reuses the full server config with mode-aware validation today, so secrets it never uses can sit in its file. Worth hardening once the split topology is in real use.
Each of these, with the argument behind it, is in Decisions.