Security model
How HostSSH protects secrets, tenants, and the control-plane↔agent channel — sealed env, Postgres RLS, mTLS, and the signed trust root.
Security model
HostSSH's security rests on four independent mechanisms. Each assumes the others might fail, so a breach of one layer doesn't hand over everything.
- Sealed secrets — secret values are encrypted end to end; only the agent can read them.
- Row-level security (RLS) — tenants are isolated at the database, not just in application code.
- mTLS + signed identity — the control plane and agents authenticate each other cryptographically.
- The trust root — licenses, releases, and the agent binary are signed and verified offline.
1. Sealed secrets (env value custody)
Goal: a secret env value (DATABASE_URL, API keys) is never readable by anyone who can see the
database, the job queue, or the deploy logs — only by the agent that runs the code.
Mechanism:
- The control plane splits env into plain (
env) and secret (secretEnv). Secret values are encrypted into AES-GCM envelopes (lib/platform/secretson the TS side) before they're written to Postgres. The database stores envelopes; plaintext is never persisted; the UI masks values. - A deploy job carries plain env in
envand sealed envelopes insecretEnv. Job validation rejects a job that tries to carry a plaintextsecretEnv. - The agent decrypts envelopes with
HOSTSSH_ENC_KEY(agent/internal/secrets.DecryptEnvelope), merges the values into the container's runtime env, and persists them to a local0600store so redeploys/restarts don't need the control plane to re-send them. - A cross-language test vector proves the Go agent decrypts what the TS control plane sealed.
Operational rule: HOSTSSH_ENC_KEY must be identical across the control plane and agents and
carried byte-for-byte if the control plane moves — otherwise previously sealed secrets become
undecryptable. It's part of the off-box secret backup (scripts/backup-secrets.sh, age-encrypted).
Known posture note: for dockerfile builds, decrypted env currently reaches the builder (build
args), so a from-source build with secrets can bake them into image layers. Keep secrets out of build
materialization where possible, or treat the image as sensitive. Tracked with guard G11's forward fix.
2. Row-level security (tenant isolation)
Goal: in a multi-tenant deployment, one tenant can never read another's rows — even if application code has a bug.
Mechanism:
- Tenant-scoped tables enable Postgres RLS (
schema-rls-tenant.sql, migration0014-rls-tenant), with awithTenant(...)chokepoint that sets the tenant context per request. - The app connects as a role with
NOBYPASSRLS, so the policy is enforced by the database, not trusted to the ORM. Migrations run as a separate, higher-privileged role. - A tenant-bleed test proves a second tenant's query returns zero cross-tenant rows.
Production gate (p0-rls-prod-proof): RLS must be proven against real Postgres — run
scripts/setup-db-roles.sql, set the app vs migrator DB URLs, run the tenant-bleed test, and capture
/healthz + boot-log evidence — before multi-tenant GA. RLS boot failures must be operationally
blocking. This is owner-gated (it changes live DB ownership/roles) and still open; see the TODO.
3. mTLS + signed identity (the control-plane↔agent channel)
Goal: the agent only talks to the real control plane, the control plane only accepts real agents, and a compromised edge can't impersonate either.
Mechanism:
- Activation (
hostssh license activate) verifies the license signature offline, then receives a signed activation token and — when enrolled — an mTLS client certificate + key (persisted0600). - All later calls (heartbeat, job claim/report, fleet reads) go through a
secure.Clientthat presents the client cert. The control plane pins TLS (HOSTSSH_TLS_PINS) so a MITM cert is rejected. - Job reports are fenced by license + fingerprint: an agent can only report on jobs pinned to its own fingerprint; sibling-host log injection after claim is rejected.
- The Web-SSH relay dials outbound from the agent and presents the same client identity; sessions are tamper-evident (hash-chained recording metadata reported on the heartbeat).
Gate (fail-open-spine-gate, task-1017): the full CA/CSR cert-minting service is the remaining
piece — enforce mTLS at the edge/API and mint a real per-agent cert at activation before flipping any
native customer traffic. Tracked open.
4. The trust root (licenses, releases, the binary)
Goal: you can verify what you're running and what entitles you to run it, without trusting a server.
Mechanism:
- Licenses are Ed25519-signed. The agent embeds the public key and verifies the signature and refreshes offline, with a grace clock — so a control-plane outage never bricks a box (guard G5). The private key mints licenses; the public key ships in the release binary.
- Releases are signed with minisign.
install.shembeds the public key and verifies the binary + checksums + SBOM before installing (release-provenance.shrefuses to publish an unsigned/placeholder release;check-release-trust.shfails closed if the key is missing). - The install path (
get.hostssh.com/dl.hostssh.com) is the only non-manual way to stand up or patch a node, and it's gated on this signed channel.
Gate (release-mirror-dark): the minisign key is still the placeholder and the mirror has never
gone live — a pure owner ceremony (mint key, replace placeholder, set release secrets, bind the
domains, tag a release). Until then, installs fall back to manual. Tracked open.
Other guards worth knowing
- Fail-closed secrets —
AUTH_SECRET/WEB_SSH_SECRETthrow in production if unset; the relay refuses to boot without a token. - Heartbeat bounds — the control plane bounds/validates every heartbeat field (utilization 0–100, non-negative counters) before persisting, so a malformed agent can't corrupt fleet state.
- Web-SSH ownership fencing — pending session claims are fenced by license + registered agent fingerprint before a session is consumed.
- Attack-surface SSRF guards — the self-scan and scraper pin resolved public IPs and share an
isBlockedHostblock-list so they can't be turned against internal targets. - Container hardening by default —
no-new-privileges, cap-drop (G15); host firewall default-deny with per-source SSH rate limiting (G16/G18); re-applied on boot (G19). - Auth brute-force protection — sign-in + TOTP are rate-limited and replay-protected (G17).
- Operator-plane license scoping (2026-07-12, migration
0029) — the webhook-endpoint and email-API-key operator routes areagentGate-authorized (any valid license bearer), so they now scope every create/list/delete bylicenseIdForKey(gate.key)(an addedlicense_idcolumn). A license holder can no longer enumerate or delete another tenant's subscriptions/keys. Closes two documented cross-tenant IDORs before customer license issuance. - Header-only cron secrets (2026-07-12) — the monitor/alert cron endpoints accept their shared
secret ONLY in
Authorization: Bearer, never a?key=query param (which would leak into proxy/access logs). Comparison istimingSafeEqual, fail-closed. - Sub-admin invite —
createAdminActionisadmin.manage_admins-gated (super-only); the invited admin's temporary password is minted server-side, shown ONCE, and only its bcrypt hash persists.
Where the mechanisms live
| Mechanism | Code |
|---|---|
| Sealed secrets (seal) | control-plane/web/lib/platform/secrets |
| Sealed secrets (unseal) | agent/internal/secrets/ |
| RLS | schema-rls-tenant.sql, lib/**/withTenant, scripts/setup-db-roles.sql |
| mTLS / secure client | agent/internal/secure/, config.go (cert/key paths) |
| License verify | agent/internal/license/ (embedded pubkey) |
| Release signing | agent/install.sh, release-mirror.sh, release-provenance.sh, check-release-trust.sh |
| Hardening | agent/internal/harden/, firewall engine |
| Rate limiting | control-plane/web/lib/**/ratelimit |
For the customer-facing view, see docs/user/secrets-and-env.md and
docs/user/hardening.md. For the license trust root, see
docs/LICENSING.md.