v3.0.0: harden the HTTP surface around the envelope crypto - #6
Open
Sebby1770 wants to merge 1 commit into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, nonosniff, noReferrer-Policy, and the session cookie carried neitherSecurenorSameSite. All added, plus COOP/CORP and HSTS (HTTPS requests only, so plain-HTTP dev is unaffected).no-storenow goes on every response carrying plaintext, ciphertext, key material, or a backup archive.send_fileotherwise labels those like static assets, so a proxy or the browser's disk cache could retain a decrypted image.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.jsturned 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_KEYhad 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.keywith 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.pySUPPORTED_METADATA_VERSIONSwas 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
.iesblob 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:
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_passphrasederived 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-stageDockerfilerunning as non-root with aHEALTHCHECK, and acompose.yamlwith a memory limit sized against Scrypt's per-decrypt footprint.Verification
ruff checkandruff format --checkcleanHttpOnly; SameSite=Lax, link throttle returning 20×404 then 429script-src 'self'with no console errorsCompatibility
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 aSecurecookie is silently dropped over plain HTTP; this is called out in the README and.env.example.🤖 Generated with Claude Code