These are non-negotiable rules enforced in code, not guidelines.
- Private keys are never stored by Callis. Ever.
- If a keypair generation feature is offered in the UI, the private key is shown once in a modal immediately after generation, then permanently discarded. It is never written to the database, never logged, never transmitted again after that single display.
- Public key text is stored in the database but is never returned in any API response or rendered in the UI after the initial upload. Only metadata is shown: fingerprint (SHA-256), label, key type, date added, last used.
- Admins can view key metadata for any user. They cannot see full public key text for another user's key.
- The
AuthorizedKeysCommandendpoint returns raw public key text tosshd— but this endpoint is network-isolated (internal Docker network only) and is never accessible through the public web UI port.
- On first start with an empty database, all web requests redirect to
/setup(enforced bySetupGuardMiddleware). - The setup wizard creates the initial admin account and requires TOTP enrollment before granting access.
- Once any user exists in the database, the account-creation step (
GET /setupandPOST /setup) returns 404 and cannot be re-triggered. The TOTP enrollment step (/setup/totp) remains accessible only to the session established during that wizard run, until enrollment is completed. SECRET_KEYis auto-generated if not provided in.envand persisted to/data/.secret_key(entrypoint.shusesopenssl rand -hex 32; the API fallback usessecrets.token_hex(32)).- File permissions are enforced on every boot:
/data(700),.secret_key(600),callis.db(600), SSH host key (600).
- Protected routes require a valid session via shared authentication dependencies (
get_current_user,require_totp_complete,require_role). Public routes (/login,/setup,/health) are intentionally left accessible without those dependencies. Session state is loaded intorequest.state.userbySessionMiddleware, but access is gated at the route level. - Sessions are JWTs stored in
httpOnly,Secure,SameSite=Strictcookies. - JWTs are never returned in response bodies, never stored in localStorage, never embedded in URLs.
- TOTP is mandatory. No user may access protected pages until TOTP enrollment is complete. This is enforced via
require_totp_completeroute dependencies and byTOTPGuardMiddleware, which redirects authenticated users without enrolled TOTP to/totp/setup. - After TOTP setup, every login requires password AND TOTP code. There is no "remember this device."
- Failed login attempts (wrong password or wrong TOTP) return the same error message and take the same time to respond (constant-time comparison). This prevents both user enumeration and timing attacks.
- A TOTP code is single-use per login: once a code is accepted, the same code (and any earlier time-step) is rejected for that user, so a code observed in transit cannot be replayed within its validity window.
- Sessions expire after idle timeout (default: 30 minutes) and have an absolute maximum lifetime (default: 8 hours).
- Sessions can be revoked server-side. Each session JWT embeds the user's
session_epoch; logging out increments it, so every outstanding token for that user stops validating — logout is a real server-side revocation, not just a client-side cookie delete.
- Role hierarchy:
admin>operator>readonly. - Role checks use FastAPI dependencies (
require_role("admin")), applied at the route level — never scattered inline. - Users cannot elevate their own role.
- The audit log (and the dashboard's recent-activity feed) is admin-only.
- Non-admins see only the hosts assigned to them in the web UI, with other users' assignments stripped — the host inventory and access map are not disclosed to low-privilege accounts. Admins see the full inventory and the assignment map. (The sshd-facing internal API has always filtered by assignment; the web view now matches.)
adminusers have full access.- A user can never view another user's key text, only fingerprints and metadata.
- Audit log entries are append-only. No API route or UI action can delete or modify them.
- All auth events, key events, and admin actions are logged.
- Logs include: timestamp, actor, action, target, source IP, and action-specific metadata.
key_usedentries (and keylast_used_attimestamps) are recorded when sshd looks up an offered key viaAuthorizedKeysCommand. This happens at key offer time — OpenSSH provides no post-authentication hook that fires for ProxyJump (direct-tcpip) connections — so a client presenting a user's full public key could create akey_usedentry without completing authentication. This grants no access. For the authoritative record of accepted vs. failed authentications, use sshd's VERBOSE connection log.
Every HTTP response carries:
Content-Security-Policy: default-src 'self';
script-src 'self' 'sha256-…' (hash-allowlisted framework hydration scripts);
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains (when HTTPS)
There are no CDN dependencies: all CSS and JavaScript is compiled into the
image and served from 'self'. Inline scripts are disallowed except for
SvelteKit's own hydration bootstrap, which is allowlisted by content hash at
render time. No eval, no inline event handlers.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile none
AuthorizedKeysCommand /etc/ssh/auth-keys.sh %u %f
AuthorizedKeysCommandUser root
HostKey /etc/ssh/host_keys/ssh_host_ed25519_key
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
AllowTcpForwarding local
GatewayPorts no
X11Forwarding no
PermitTunnel no
AllowAgentForwarding no
PermitTTY no
ForceCommand /etc/ssh/callis-cmd.sh
MaxAuthTries 2
LoginGraceTime 15
ClientAliveInterval 300
ClientAliveCountMax 2
LogLevel VERBOSE
Banner /etc/ssh/banner.txt
Note on PermitTTY no + ForceCommand /etc/ssh/callis-cmd.sh: ForceCommand applies only to shell/exec SSH requests, not to direct-tcpip channel requests (which is what ProxyJump uses). The command router allows only two whitelisted commands (resolve <tag> and list) — all other input is denied with "This account is not available." Tag input is sanitized to [a-z0-9-] only.
- Only Ed25519 host keys are generated and used.
- Only Ed25519 and RSA ≥ 4096 bit client public keys are accepted. This is enforced in the API on upload, before the key is stored.
- The strict KexAlgorithms, Ciphers, and MACs list above eliminates all deprecated or weak algorithms.
Mitigations:
MaxAuthTries 2— limits attempts per connectionLoginGraceTime 15— short window for auth- Fail2ban sidecar — bans IPs after 3 failures in 10 minutes
- Non-standard port (default 2222) — reduces automated scanner hits
Mitigations:
slowapirate limiting: 5 attempts per 15 minutes per IP on/auth/login- TOTP required — even correct password is insufficient alone
- Constant-time comparison — no user enumeration via timing
Mitigations:
httpOnly— not accessible to JavaScriptSecure— only sent over HTTPSSameSite=Strict— not sent on cross-site requests- Short idle timeout (30 minutes default)
- Absolute session lifetime (8 hours default)
Mitigations:
- Instant key revocation via
AuthorizedKeysCommand— no polling lag - Per-user OS accounts — compromised account is isolated
- Admin can deactivate account immediately from web UI
Mitigations (defense-in-depth, three layers):
- Network isolation — port 8081 bound separately, never exposed in
docker-compose.yml, only reachable within the Docker network - Internal shared secret — all internal API requests (
/internal/keys/,/internal/resolve/,/internal/hosts/) require a validX-Internal-Secretheader. The secret is derived deterministically fromSECRET_KEYviaHMAC-SHA256(SECRET_KEY, "callis-internal"), so no additional env var is needed. Requests with missing or invalid secrets are rejected with 403. - SSH key authentication —
callis-cmd.shonly executes after SSH key auth succeeds. The username comes from$(whoami)(the OS user created byauth-keys.shafter key verification), not from client input.
Mitigations:
callis-cmd.shuses a strictcasestatement — onlyresolve <tag>andlistare allowed- Tag input is sanitized via
tr -cd 'a-z0-9-'— all other characters are stripped - Default case denies access (same behavior as previous
/sbin/nologin)
Mitigations:
- Server-side rendering via Svelte — all interpolated output is auto-escaped
- Strict CSP — every source restricted to
'self', no CDNs, no inline scripts beyond hash-allowlisted framework hydration - No user-controlled content is rendered unescaped anywhere (no
{@html})
Mitigations:
SameSite=Strictcookies prevent cross-site form submissions from including the session cookie- The SSR server rejects any form-encoded POST whose
Originhost does not match the requestHost(seefrontend/src/hooks.server.ts) - The JSON API is bound to loopback inside the container and only reachable through the SSR server
Mitigations:
- Host keys generated once and persisted in a named volume
- Not regenerated on container restart
- Users instructed to verify host key fingerprint on first connection (displayed in web UI)
- Network-layer attacks — DDoS, IP spoofing. These must be handled at the network/firewall layer (OPNsense, AWS security groups, etc.).
- Compromised host machines — Callis controls access to hosts, not security on those hosts.
- Physical access — out of scope.
- Supply chain attacks on dependencies — mitigated by pinned dependencies in
uv.lockbut not eliminated. - A compromised admin account — a compromised admin can revoke other users' access. Protect admin accounts accordingly.