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.
- 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.
SecretRedactionFilteris installed on the root logger at startup. Any value passed tosecurity.register_secretis scrubbed from every log record, error, and audit line (replaced with***REDACTED***). - CI secret scan.
tests/security_scan.pyruns in thesecurity-scanCI job and fails the build on a likely committed live secret.
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-keyThe 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.
The transport is a single-bearer-key gateway; transport/security.py provides
the credential, throttle, and session primitives for any account-bearing
surface:
- Credential storage —
hash_password/verify_passworduse Argon2id when theargon2-cffilibrary 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_rehashdrives 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_kvrows over an internal channel and never returns them to clients. - Brute-force throttling —
Throttlekeeps 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 sessions —
SessionStoreissues short-lived tokens and keeps a server-side revocation set so logout/admin-revoke takes effect immediately, before natural expiry. - Audit logging —
audit_auth_eventwrites 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.
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.
The plaintext-HTTP → HTTPS redirect is BREAKING for HTTP-only clients and is rolled out in stages, each config-toggled:
- Monitor — leave
GA_ENFORCE_HTTPS_REDIRECT=0; plaintext hits are served and logged so affected clients are identified. - Notify — announce the cutover date and the HTTPS endpoints to those clients before flipping the flag.
- 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. - CSP enforce — CSP ships report-only (
GA_CSP_ENFORCE=0) first; after triaging report-only violations, setGA_CSP_ENFORCE=1to enforce.
Rollback: set GA_ENFORCE_HTTPS_REDIRECT=0 and/or GA_CSP_ENFORCE=0 and
restart — no code change.
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.
- Server-side validation —
security.validate_strchecks type, length, and format and rejects invalid input; the transport already validatescrew_id(CREW_ID_RE),agent(_validate_agent), and change names server-side, independent of any client checks. - Injection-safe data access — all
auth_kvaccess 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 encoding —
encode_html_text,encode_html_attr, andencode_url_componentencode untrusted data for its rendering context. - CI static check —
tests/security_scan.pyflags string-built SQL and fails the build (security-scanjob).
- 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 --strictpasses.