Skip to content

Invariants

Rules the code depends on. Each one is load-bearing: something elsewhere is written assuming it holds, so changing one is a design decision, not a refactor. Code comments cite this file by name.

For what ssoossh deliberately does not do, and why, see Decisions. That page answers “why is there no X”; this one answers “what may I rely on”. For the operator-facing version of the same rules, see the security model.

Server config is the outer bound. Nothing reachable over HTTP can make issuance more permissive than the loaded configuration allows: not longer-lived, not more capable. Client requests ask, the web UI narrows, server config gates. Options a deployment does not permit are trimmed rather than rejected, so both the requested and the narrowed sets are carried together and the approval page can show what was asked for alongside what would actually be granted. A human authorizing issuance has to see 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.

no-touch-required is granted only for service certificates, and only for an enrolled hardware-backed sk- key. Never for client-generated keys. verify-required is never used.

There is no host certificate type, and no secure host-verification mechanism to justify one. ssoossh host is local principal-mapping tooling for AuthorizedPrincipalsCommand and has no server side. Do not add a host type without solving host verification first.

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. The client manages its own keypair.

The CA key is reached through a CAKeySource interface, never hardcoded to one backend. Four sources sit behind that seam (an inline PEM, a key file, an ssh-agent and a PKCS#11 token), which is also what lets the signer run as a separate, minimally-privileged process.

Group membership never appears in a certificate, and persisted group rows never grant anything. Authorization – admin, SOC, auditor, fleet key admin, and the certificate policy gates – is evaluated from the session identity only. Roles and policy come from the session identity, never from database rows, with one narrowing exception: 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. Groups can change between sessions, so model.User keys history to a stable subject identifier rather than storing claims.

user_groups is a snapshot for notification fan-out and display: it answers “who should this reach”, never “may this caller do this”. It was added by directory sync (see LDAP enrichment), which retired the older, stronger wording that group membership is never persisted at all. What survives is the half that was load-bearing: nothing reads those rows to grant anything. The one decision they feed is the narrowing above, which can only remove a role group the session’s claim already carries, and only when an operator opts in (see Authoritative role groups).

Roles come from the session; account lists come from the database. The two halves of an identity are refreshed on opposite schedules, deliberately:

  • Groups are read at login and never re-read, which is what makes the session lifetime the revocation window for a role. Nothing stored can grant one. With ldap.authoritative_role_groups on, every request narrows them: a role group the latest directory read for a linked user no longer lists stops counting on that request, and counts again once a read lists it again. The session keeps the login’s claim, so narrowing never compounds.
  • Account lists and extra fields – other_accounts, service_accounts, and the configured extras – are re-read from the database on every authenticated request, rebuilt the way a login builds them: the users row first, then the directory values over the top.

The second half is what stops a live session outliving a directory change. Approval linkage reads those lists to decide which principals a certificate may carry, so a session holding a login-time snapshot could go on approving a certificate for a shared account the directory had already taken away. The refresh fails open – a database error leaves the session’s values in place and logs, because no principals at all is a worse failure than briefly stale ones.

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. RequestedOptions.SourceAddresses is unverified. The address policy consults is the one the server observed.

The client never opens a listening port. No loopback redirect, no local callback server. The browser approval flow works without one.

client/ may not import server/. Anything shared travels through internal/ – internal/api for the HTTP client, internal/apitypes for the wire contract. This is why apitypes.CertificateRequestStatus duplicates model.CertificateRequestStatus rather than importing it; server/model remains the source of truth for the database, and Wire types covers how the two are kept from drifting.

The wire contract is cross-repository. internal/apitypes and the internal/principalsmap file format are also spoken by pam_ssoossh (github.com/mnestor/ssoossh-pam), which is written in C and shares no code with this repository. A breaking change to either is a change in two repositories, not one. docs/wire-contract.json is what makes such a change visible: it versions the shapes, and make wire-contract-check gates it. See wire-types.md.

internal/ may not import back up into client/ or server/. A caller maps its own config into whatever the internal package defines, which is why api.Config exists rather than internal code taking a client/config.Config.