This is a backlog of deferred hardening work. Each item is a candidate to be planned and implemented later; none of them is a current defect that makes the server unusable. The items came out of a threat-model review that asked one question: if an attacker walks off with a full dump of the PostgreSQL database, what can they still read or crack, and what should we do about it?
This document only records the gaps. It does not describe anything that is already implemented, and it does not propose code changes here.
The baseline is better than a naive server's, because secrets are stored hashed or encrypted, and the key material lives outside the database. The table below is the full accounting.
| Data | Protection at rest | If the dump leaks |
|---|---|---|
| User passwords | Argon2id PHC, salted (src/db/user_repo.rs) |
Offline cracking only. |
| Client secrets | Argon2id PHC (src/db/client_repo.rs) |
Offline cracking only. |
| TOTP secrets | AES-256-GCM under SHA256(totp_key) (src/crypto/totp.rs) |
Safe only if the key is not in the dump. |
| TOTP recovery codes | Argon2id PHC (src/db/user_repo/totp.rs) |
Offline cracking only. |
| DCR registration access tokens | Keyed HMAC hash (src/db/client_repo.rs) |
Safe; the pepper is not in the database. |
| Emails, client names, contacts, redirect URIs, scopes, entitlements | Plaintext | Full disclosure. |
jwks (client public keys) |
Plaintext | Public by design. |
tls_client_auth_subject_dn and cert thumbprints |
Plaintext | Identifier disclosure. |
is_admin, disabled, must_change_password, timestamps |
Plaintext | Metadata disclosure. |
The critical dependency is outside the database. The token pepper and the TOTP
encryption key are supplied through environment variables or mounted files
(src/config/raw.rs, src/config/build.rs), never through a table. A pure
database dump therefore does not yield them, and every hashed or encrypted
column above stays opaque. This separation is the single most important
protection the server has today, and the gaps below are mostly about not
depending on it alone.
Each item names the risk, the change to plan, and the files it would touch. The list is deliberately short and ordered by value.
email
lookup so case-insensitive uniqueness still works.src/db/user_repo.rs, src/db/client_repo.rs,
src/db/redirect_repo.rs, src/keys.rs, migrations/.SHA256(totp_key)
(src/crypto/totp.rs) and no KDF stretching. If the key file leaks alongside
the dump, every TOTP secret falls immediately. A low-entropy passphrase is
cheap to brute-force when a single hash is all that stands in the way.src/crypto/totp.rs, src/cli/secret.rs.src/crypto/totp.rs, src/keys.rs.src/config/, docs/DEPLOYMENT.md, docs/SECURITY.md.docs/SECURITY.md, RUNBOOK.md.pgcrypto. A dump could be taken by a read-only job and leave no trace.docs/DATABASE.md, docs/DEPLOYMENT.md.The attack surface and the existing controls are documented in SECURITY.md; this section lists only the mitigations to close the residual risks.
This section covers a second threat-model question: if an attacker is already
inside the image's trust boundary, how could they reach the container or the
host, and how should we reduce that risk? The image is already hardened: it runs
as non-root UID 65532, uses a read-only root filesystem, drops all capabilities,
sets allowPrivilegeEscalation: false and seccompProfile: RuntimeDefault,
ships no shell, builds a static musl binary from digest-pinned bases, and
produces an SBOM, provenance, and a cosign signature in CI. The remaining vectors
are therefore narrow, and none of them is a current defect. This section lists
the mitigations to implement.
/proc/self/environ), appear in docker inspect, are inherited by children,
and leak into logs and crash dumps; an attacker with code execution reads the
token pepper and TOTP key directly.0400, owned by 65532) and use the already-supported
D_OAUTH_TOKEN_PEPPER_FILE / D_OAUTH_TOTP_ENCRYPTION_KEY_FILE variables
(src/config/raw.rs, src/config/build.rs).deploy/k8s/deployment.yaml, deploy/k8s/secret.yaml,
compose.yaml, docs/DEPLOYMENT.md, docs/CONFIGURATION.md.:latest tag with
imagePullPolicy: IfNotPresent; a compromised registry tag could be pulled if
the CI digest-rewrite step does not run.deploy/k8s/deployment.yaml, deploy/k8s/kustomization.yaml.deploy/k8s/ (new networkpolicy.yaml), docs/DEPLOYMENT.md.automountServiceAccountToken: false on the pod.deploy/k8s/deployment.yaml.RuntimeDefault.deploy/k8s/deployment.yaml, docs/DEPLOYMENT.md.cargo deny do not catch a zero-day in a
trusted crate.cargo vet/cargo crev review..github/workflows/, fuzz/, deny.toml.docker exec / kubectl exec into the running container bypasses
every image control.docs/OPERATIONS.md, docs/DEPLOYMENT.md.The server-rendered pages (src/ui/) are intentionally minimal unstyled HTML
with no JavaScript (the page CSP sets script-src 'none'). This section lists
the usability work to make them presentable without weakening that posture:
any styling must keep the CSP and the login CSRF tokens intact.
role="alert" preserved), and a responsive width. Keep zero
JavaScript so script-src 'none' still holds; if any inline <style> is
used, scope the CSP with a hash rather than unsafe-inline.src/ui/ (all page templates), src/routes.rs (middleware order
if a static-asset route is added), docs/ARCHITECTURE.md (if the CSP
changes).otpauth:// URI as text
(src/crypto/totp.rs), so enrolling means hand-typing the secret into the
authenticator app. Users expect to scan a QR code.qrcode crate emitting SVG embedded in the page),
so no client-side script is needed and script-src 'none' still holds.
Keep showing the secret in text form as a fallback, alongside the recovery
codes already displayed once at enrollment.src/crypto/totp.rs (URI builder), src/ui/login.rs (TOTP setup
template), Cargo.toml/Cargo.lock (new dependency, must pass
cargo deny check).Cache-Control: no-store is applied at a single choke point on the
public router (src/middleware/security_headers.rs), with /.well-known/…
metadata exempt, so it covers every OAuth endpoint today. The policy is
nevertheless implicit: metadata discovery is merely left unmarked and its
caching relies on browser heuristics, the plaintext health listener never
passes through the layer, and a handler exercised off-router would carry no
header. A new route mounted outside build_router_with_options would silently
miss the policy./.well-known/oauth-authorization-server a positive Cache-Control (for
example public, max-age=…) so metadata caching is intentional and tunable;
mark the health probes no-store (or mount the security-header layer on the
health listener); and consider Vary: Cookie on the authenticated pages as
defense in depth. Keep metadata cacheable; do not extend no-store to it.src/middleware/security_headers.rs, src/routes.rs,
src/serve.rs, src/state/health.rs, docs/SECURITY.md.Ranked by value against effort:
Recommendation: implement 1, 2, 5, 7, 8, 9, and 10 first. Item 1 closes the largest plaintext disclosure, item 2 removes an easy offline win if the key file leaks, item 5 is mostly writing down a procedure the team already needs, and items 7 through 10 are high-value, low-effort container and host controls.
Cache-Control, or is
leaving it to browser heuristics acceptable (item 16)?