Skip to content

v3.0.0: harden the HTTP surface around the envelope crypto - #6

Open
Sebby1770 wants to merge 1 commit into
mainfrom
feat/v3.0.0-defense-in-depth
Open

v3.0.0: harden the HTTP surface around the envelope crypto#6
Sebby1770 wants to merge 1 commit into
mainfrom
feat/v3.0.0-defense-in-depth

Conversation

@Sebby1770

Copy link
Copy Markdown
Owner

The envelope cryptography in this project is careful. The layer a browser actually talks to had no controls at all.

Browser-side gaps

There was no CSP, no frame-ancestors, no nosniff, no Referrer-Policy, and the session cookie carried neither Secure nor SameSite. All added, plus COOP/CORP and HSTS (HTTPS requests only, so plain-HTTP dev is unaffected).

no-store now goes on every response carrying plaintext, ciphertext, key material, or a backup archive. send_file otherwise labels those like static assets, so a proxy or the browser's disk cache could retain a decrypted image.

The README has claimed "decrypted responses are non-cacheable" since v1.0. No such header was ever sent. It's true now.

Keeping the CSP strict (script-src 'self', no inline allowance, no nonce) meant moving the dashboard's inline <script> into a file — the alternative is 'unsafe-inline', which defeats the policy, or nonce plumbing through every render. While there, static/js/auth.js turned out to be orphaned dead code from an earlier lineage, referencing markup (#password-meter-bar) and routes (/images/<id>/preview) that no longer exist. It's rewritten against the real form and actually loaded.

The signing secret

SECRET_KEY had a hardcoded fallback (dev-secret-change-me-…) that a deployment could run on silently. It signs both session cookies and API tokens, so a known value forges either.

Now: with none configured the app generates a random key and persists it to $IES_INSTANCE_DIR/secret.key with owner-only permissions — the quick start still works with zero configuration — and it refuses to boot on a short or previously published value.

Two latent traps in crypto.py

SUPPORTED_METADATA_VERSIONS was declared but never consulted. An envelope claiming any version at all was decoded on the assumption that its fields meant what this build expects. Now enforced.

The Scrypt validator rejected anything below the current default, which quietly made the default un-raisable — bumping the cost would have made every existing vault file and .ies blob undecryptable. The accepted floor is now its own constant, so the default could finally move: N=2^14 → 2^16.

2^17 was measured and rejected:

N memory time
2^14 (old) 16.8 MB 79 ms
2^16 (new) 67.1 MB 308 ms
2^17 134.2 MB 611 ms

The KDF runs on every decrypt, not just at login, so 2^17 would let a handful of concurrent decrypts exhaust a small host.

Also fixed: _wrap_key_with_passphrase derived its key from default arguments while writing the cost into metadata as separate literals. They agreed only by coincidence — a test that re-wrapped at a different cost produced an undecryptable file, which is how this surfaced.

Throttling stopped at the login form

Registration was the worst gap: it mints an RSA-3072 key pair, making it an unauthenticated CPU amplifier. Added throttles for registration, decrypt attempts, and capability-link resolution (unauthenticated and CSRF-exempt by design, so the bearer token is the only secret). Counters reuse the existing SQLite guard table, so they survive a restart.

Decrypt is keyed per account, not per asset — keying on the asset would let one user throttle another's shared image.

Password policy

Enforced at the storage layer, not the view: registering with a strong password and then rotating to a weak one was otherwise a one-request bypass. The client-side meter mirrors exactly the rules the server enforces, so it never approves something the server will reject.

Also

GET /healthz (unauthenticated, deliberately detail-free), a multi-stage Dockerfile running as non-root with a HEALTHCHECK, and a compose.yaml with a memory limit sized against Scrypt's per-decrypt footprint.

Verification

  • 63 → 92 tests, 85% coverage, ruff check and ruff format --check clean
  • Verified against a running server: all headers present, cookie HttpOnly; SameSite=Lax, link throttle returning 20×404 then 429
  • Verified in-browser that the strict CSP does not break the UI: registration completes, dashboard renders, and the externalized script's algorithm toggle works under script-src 'self' with no console errors

Compatibility

Vault files written by earlier releases keep decrypting — that's the point of splitting the Scrypt floor, and there's a regression test for it. Local HTTP development now needs IES_SESSION_COOKIE_SECURE=0, since a Secure cookie is silently dropped over plain HTTP; this is called out in the README and .env.example.

🤖 Generated with Claude Code

The cryptography here was already careful. The layer a browser actually talks
to had no controls at all: no CSP, no frame-ancestors, no nosniff, no
Referrer-Policy, and a session cookie with neither Secure nor SameSite. Add
them, and serve no-store on every response carrying plaintext, ciphertext, key
material, or a backup — send_file otherwise labels those like static assets, so
a proxy or the browser's disk cache could retain a decrypted image. The README
had claimed that last property since 1.0; no such header was ever sent.

Keeping the CSP strict meant the dashboard's inline <script> had to move to a
file, since the alternative is 'unsafe-inline' or nonce plumbing through every
render. static/js/auth.js turned out to be orphaned code from an earlier
lineage, referencing markup that no longer exists; rewrite it against the real
form and actually load it.

SECRET_KEY had a hardcoded fallback that a deployment could run on silently.
Generate and persist a random key instead when none is configured, so the quick
start still works, and refuse to boot on a short or previously published value.

Two latent traps in crypto.py:

SUPPORTED_METADATA_VERSIONS was declared but never consulted, so an envelope
claiming any version was decoded assuming its fields meant what this build
expects. The Scrypt validator rejected anything below the current default,
which quietly made the default un-raisable - bumping it would have made every
existing vault file undecryptable. Split the accepted floor into its own
constant, then raise the default to 2**16. 2**17 was measured and rejected: at
~611ms and 134MB per derivation, and with the KDF running on every decrypt
rather than only at login, a few concurrent decrypts would exhaust a small host.

Throttling stopped at the login form. Registration was the worst gap because it
mints an RSA-3072 key pair, making it an unauthenticated CPU amplifier. Add
throttles for registration, decrypt attempts, and capability links, reusing the
existing SQLite guard table so counters survive a restart. Enforce the new
password policy in storage rather than in the view, because registering strong
and then rotating weak was otherwise a one-request bypass.

Also fixes _wrap_key_with_passphrase deriving its key from default arguments
while writing the cost into metadata as separate literals - they agreed only by
coincidence.

Tests: 63 -> 92, coverage 85%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant