Skip to content

Security: Versent/ghostship

Security

docs/security.md

Security hardening (TRN-70)

This document is the operational companion to the OpenSpec change trn-70-security-hardening. It records how the four security capabilities are enforced in the transport, how secrets are rotated without a code change, and how the one BREAKING change (plaintext HTTP → HTTPS redirect) is staged and communicated to clients.

The security primitives live in transport/security.py; the transport wires them in transport/server.py. Every protection is togglable via configuration so a rollback needs no code change.

Secrets management

  • Single accessor. All secret reads go through security.get_secret(...) (and the transport's _load_api_key / _load_or_create_file_secret, which register their values for redaction). The API key is a Podman secret mounted at /run/secrets/ga-api-key.
  • Redaction. SecretRedactionFilter is installed on the root logger at startup. Any value passed to security.register_secret is scrubbed from every log record, error, and audit line (replaced with ***REDACTED***).
  • CI secret scan. tests/security_scan.py runs in the security-scan CI job and fails the build on a likely committed live secret.

Rotating a secret (no code change)

The GA_API_KEY is a Podman secret, not baked into the image or source, so rotation is a re-create + restart — no rebuild, no code edit:

# Rotate the transport API key.
podman secret rm ga-api-key
printf '%s' "$NEW_KEY" | podman secret create ga-api-key -
podman restart ghost-academy   # container re-reads /run/secrets/ga-api-key

The file-URL signing secret (ga-file-secret) is persisted under the data mount and can be overridden with the GA_FILE_SECRET env var; rotating it invalidates outstanding presigned URLs (which are short-lived by design).

Because the value is read at process start through the single accessor, the new value is picked up on restart with no source change — satisfying the "secret rotation without a code change" requirement.

Authentication security

The transport is a single-bearer-key gateway; transport/security.py provides the credential, throttle, and session primitives for any account-bearing surface:

  • Credential storagehash_password / verify_password use Argon2id when the argon2-cffi library is present (OWASP first choice) and stdlib scrypt as the accepted memory-hard fallback. The hash string is self-describing (algo + params + salt); cleartext is never stored. needs_rehash drives opportunistic rehash-on-login to upgrade legacy hashes.
  • No credential in responses — no API returns a password, hash, or reversible form; the auth extract/inject path moves only opaque auth_kv rows over an internal channel and never returns them to clients.
  • Brute-force throttlingThrottle keeps a sliding window of failures keyed on account + source, locks out past the threshold with a generic error, and resets on a successful login. Backed by the shared fast store in production so it holds across instances.
  • Bounded, revocable sessionsSessionStore issues short-lived tokens and keeps a server-side revocation set so logout/admin-revoke takes effect immediately, before natural expiry.
  • Audit loggingaudit_auth_event writes a structured event (action, outcome, account, source, timestamp) for every auth/authz decision and never includes a credential or token value; the redaction filter is a second line of defence.

Transport security

SecurityHeadersMiddleware (outermost in the ASGI stack) emits the baseline headers on every response — X-Content-Type-Options: nosniff, X-Frame-Options: DENY (plus CSP frame-ancestors 'none'), and a Content-Security-Policy — and adds HSTS (max-age two years, includeSubDomains) on HTTPS responses. HTTPS is detected from the scheme or X-Forwarded-Proto (TLS is terminated at the edge).

Minimum TLS is 1.2. When the app terminates TLS directly (non-edge installs), set GA_TLS_CERTFILE / GA_TLS_KEYFILE; GA_TLS_MIN_VERSION sets the floor.

Staged cutover of the BREAKING change

The plaintext-HTTP → HTTPS redirect is BREAKING for HTTP-only clients and is rolled out in stages, each config-toggled:

  1. Monitor — leave GA_ENFORCE_HTTPS_REDIRECT=0; plaintext hits are served and logged so affected clients are identified.
  2. Notify — announce the cutover date and the HTTPS endpoints to those clients before flipping the flag.
  3. Enforce — set GA_ENFORCE_HTTPS_REDIRECT=1; plaintext requests receive a 301 to their HTTPS equivalent and no application content is served over plaintext. Health probes (/health) are exempt.
  4. CSP enforce — CSP ships report-only (GA_CSP_ENFORCE=0) first; after triaging report-only violations, set GA_CSP_ENFORCE=1 to enforce.

Rollback: set GA_ENFORCE_HTTPS_REDIRECT=0 and/or GA_CSP_ENFORCE=0 and restart — no code change.

Client notice (pre-cutover)

The Ghost Academy transport will require HTTPS for all external endpoints on the announced cutover date. Plaintext HTTP requests will be redirected (HTTP 301) to their HTTPS equivalent and will no longer be served directly. Update integrations to use the https:// endpoints before the cutover. HSTS will be sent on HTTPS responses. No endpoint paths change — only the scheme.

Input validation

  • Server-side validationsecurity.validate_str checks type, length, and format and rejects invalid input; the transport already validates crew_id (CREW_ID_RE), agent (_validate_agent), and change names server-side, independent of any client checks.
  • Injection-safe data access — all auth_kv access uses parameterized SQL (VALUES (?, ?) with bound rows); untrusted file paths are passed to transfer scripts via environment variables, never concatenated into the script body.
  • Context-aware output encodingencode_html_text, encode_html_attr, and encode_url_component encode untrusted data for its rendering context.
  • CI static checktests/security_scan.py flags string-built SQL and fails the build (security-scan job).

Verification

  • Spec-scenario tests: tests/unit/test_trn70_security.py (one test per spec scenario across the four capabilities plus audit logging).
  • Prior hardening tests: tests/unit/test_security_hardening.py (TRN-27: Podman secrets, login TOCTOU).
  • openspec validate trn-70-security-hardening --strict passes.

There aren't any published security advisories