Live site: https://sebby1770.github.io/image-encryption-system/
A Flask image vault that encrypts photos with AES-256-GCM before they touch disk. Per-image data keys are wrapped with Scrypt+AES or RSA-OAEP. Version 2.3.0 adds capability link shares, notes/favorites, ciphertext integrity checks, session idle timeout, audit CSV, and CLI rewrap/hash.
- AES-256-GCM encryption for PNG, JPEG, WEBP, GIF, BMP, and TIFF images, with a fresh 256-bit data key per image.
- Data keys wrapped with Scrypt + AES-GCM (passphrase mode) or RSA-OAEP-SHA256 (hybrid mode, 3072-bit per-user key pair generated at registration; the private key is encrypted with the account password).
- Share with another username by re-wrapping the same data key with their
RSA public key. Recipients decrypt with their password. Revoke at any time,
with optional expiry (
expires_hours/expires_days). - Capability links (
/l/<token>) for people without accounts, with optional expiry and download cap. Only the SHA-256 of the token is stored. - Rotate the passphrase wrap of an image without rewriting its ciphertext.
- Change password: new hash, RSA private key re-encrypted,
token_versionbumped so other sessions and JWTs stop working. - Delete account (
POST /account/delete) with password confirmation. - Envelope version 3 seals the image's owner, asset id, algorithm, wrap type, MIME type, format, and dimensions into the AES-GCM AAD, so ciphertext cannot be swapped between records or accounts. Version 1 files stay decryptable.
- Capture metadata is stripped before encryption: EXIF (including GPS), XMP, IPTC, PNG text chunks, and JPEG/GIF comments.
- Uploads are identified from their header and bounded (format allow-list, 64 MP pixel ceiling across all frames, 8 MB byte limit) before anything decodes them.
- Decrypted images are served only as the allow-listed type they parse as,
with
no-store,nosniff, and a sandboxing CSP. - Ciphertext SHA-256 recorded at save time and checked before decrypt.
- CSRF tokens on every HTML POST form; strict CSP (no inline script).
- Server-side session registry: logout and password changes really end sessions; 30-minute idle and 7-day absolute timeouts.
- Login rate limit (5 / 10 minutes per IP+username) and lockout after 8 failures, persisted in SQLite.
- Throttles beyond the login form: registration per address (each one mints an RSA-3072 key pair, so it is a CPU amplifier reachable without an account), decrypt attempts per account, and capability-link requests per address.
- Password policy enforced in storage, so registration, rotation, and the CLI all clear the same bar; a register-page meter mirrors the same rules.
- Scrypt at
n=2^16for new passphrase wrappings. The accepted floor (2^14) is a separate constant, so older files stay readable. - API tokens carry and require an audience, alongside
exp,iat,iss,sub, andver. - Permissions-Policy, COOP, CORP, and HSTS (on HTTPS requests) alongside the
CSP;
GET /healthzfor probes; a non-root Docker image. - Owner-only audit log, HMAC-chained and verified on every view (web, CSV
export, and
GET /api/audit). - Encrypted backup zip (ciphertext + metadata, never private keys) and restore.
- Rename, notes, and favorites on vault items.
- JWT API for listing images, sharing, links, and the audit trail.
iesCLI for offline encrypt / decrypt / keygen / inspect / verify / rewrap / hash.
Python 3.10+, Flask, cryptography (OpenSSL), Pillow, SQLite, PyJWT. PyCrypto is intentionally not used because it is unmaintained.
git clone https://github.com/Sebby1770/image-encryption-system.git
cd image-encryption-system
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
python run.pyOpen http://127.0.0.1:5000, create an account, and upload an image.
ies encrypt IN.png --passphrase 'a long secret' --out out.bin
ies decrypt out.bin --passphrase 'a long secret' --out restored.png
ies inspect out.bin
ies verify out.bin --passphrase 'a long secret'
ies hash out.bin
ies rewrap out.bin --old-passphrase 'a long secret' --new-passphrase 'rotated' --out rotated.ies
ies keygen --passphrase 'account password' --out-private key.pem --out-public pub.pem
ies encrypt IN.png --public-key pub.pem --out photo.ies
ies decrypt photo.ies --private-key key.pem --passphrase 'account password' --out restored.pngThe CLI talks only to crypto.py. It does not start Flask or write decrypted
images unless you pass --out. inspect prints algorithm and version only.
verify unwraps the data key and exits 0 or 1.
On the dashboard, choose Share and enter another username. The server:
- Unwraps the AES data key with your passphrase (AES-GCM mode) or your RSA private key (hybrid mode).
- Re-wraps that same key with the recipient's RSA public key.
- Stores the new wrap in
shares. The ciphertext file is unchanged.
The recipient sees the image under Shared with me and decrypts it with their
account password. A third user cannot unwrap the shared key. The owner can
Revoke a recipient at any time; that deletes the shares row so decrypt
fails for them. Optional expires_hours (or expires_days) stores expires_at;
an expired share is treated as revoked on decrypt. The dashboard shows the
expiry.
AES-GCM passphrase assets also have Rotate passphrase: the server unwraps the data key with the old passphrase and writes a new wrap. Shares stay valid because they hold their own RSA wrap of the same data key.
GET /audit lists your events only: login, upload, decrypt, share, revoke,
rotate, password change, delete, and backup. GET /api/audit returns the same
data as JSON.
GET/POST /account/password verifies the current password, stores a new hash,
increments token_version, and re-encrypts user-<id>-private.pem with the
new password. Other browser sessions and JWTs whose ver claim no longer
matches are rejected. RSA-hybrid decrypt then uses the new password, not the
old one. Image ciphertext is never rewritten.
POST /account/delete confirms the password (and CSRF) then deletes the
account: vault blobs, shares, RSA keys, audit rows, and the user.
GET /backupdownloads a zip of your encrypted blobs plusmanifest.json.POST /restore(dashboard form) imports that zip into the current account. Every entry is validated first, so a bad backup restores nothing. Restoring the same backup twice does not duplicate images. Images in the current (version 3) format can only be restored into the account that exported them; version 1 images can be restored anywhere.- Private keys and password hashes are never included.
- Each vault item can also be downloaded as a portable
.iesfile.
| Variable | Purpose |
|---|---|
SECRET_KEY |
Flask session signing (generated and persisted in the instance dir if unset) |
JWT_SECRET |
JWT HMAC secret (derived from SECRET_KEY if unset) |
AUDIT_HMAC_KEY |
Audit chain key; keep it off the database host (generated if unset) |
IES_SECURE_COOKIES |
1 behind HTTPS to mark the session cookie Secure |
IES_INSTANCE_DIR |
SQLite, vault blobs, and RSA keys |
IES_MAX_UPLOAD_BYTES |
Upload cap (default 8 MiB) |
IES_HSTS_SECONDS |
HSTS max-age, sent only on HTTPS requests (default one year; 0 disables) |
IES_MIN_PASSWORD_LENGTH |
Minimum password length (default 10) |
IES_REGISTER_RATE_LIMIT / _WINDOW |
Registrations per address per window (default 5 / 3600 s) |
IES_DECRYPT_RATE_LIMIT / _WINDOW |
Decrypt attempts per account per window (default 30 / 300 s) |
IES_LINK_RATE_LIMIT / _WINDOW |
Capability-link requests per address per window (default 20 / 300 s) |
A throttle limit of 0 disables that throttle.
Use strong secrets for any shared deployment.
Uploaded images are re-encoded without capture metadata when any is present, then encrypted with a random 256-bit data key using AES-GCM. The AAD seals the image's context (owner, asset id, type, and size). The selected algorithm controls how that data key is protected:
AES-GCM passphrase: Scrypt derives a wrapping key, then AES-GCM wraps the data key.RSA hybrid: RSA-OAEP-SHA256 wraps the data key with the user's public key.
The decrypted image is streamed to the authorized user and is not written to disk by the web app.
See docs/SECURITY_MODEL.md for goals, non-goals, trust boundaries, and production hardening notes.
curl -X POST http://127.0.0.1:5000/api/token \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"correct horse battery staple"}'List encrypted images (owned + shared):
curl http://127.0.0.1:5000/api/images \
-H 'Authorization: Bearer <token>'Read your audit log:
curl http://127.0.0.1:5000/api/audit \
-H "Authorization: Bearer <token>"python -m pip install -e '.[dev]'
ruff check src tests scripts run.py
ruff format --check src tests scripts run.py
mypy
pytestThe suite includes Hypothesis property tests (tests/test_properties.py):
flipping any byte of an .ies file must make decryption fail, CLI and web
uploads must round-trip, and out-of-bounds KDF parameters must be rejected
cheaply. There is also a Playwright browser test that registers two users,
uploads, views, shares, revokes, and checks that the recipient is locked out:
pip install -e '.[e2e]' && python -m playwright install chromium
pytest -m e2e tests/e2eCI runs lint, format, mypy, and pytest (85% coverage gate, 200 Hypothesis
examples per property) on Python 3.10 through 3.13, the browser test in its own
job, and a pip-audit dependency scan.
image-encryption-system/
src/image_encryption_system/
crypto.py # AES-GCM, RSA-OAEP, .ies container, key re-wrap
storage.py # SQLite, shares, audit, backup zip
web.py # Flask app, auth, share, revoke, audit, API
cli.py # ies console script
security.py # login guard, request throttles, password policy
templates/ # HTML views
static/css/ # UI styling
tests/ # pytest coverage
docs/ # Threat model
docker compose up --buildThe image builds the wheel in a throwaway stage and runs it with gunicorn as a
non-root user, with a HEALTHCHECK against /healthz. The vault database,
ciphertext, encrypted private keys, and the generated secrets all live in the
vault-data volume mounted at /data. compose.yaml sets IES_SECURE_COOKIES=1
(serve it behind TLS) and a memory limit, because Scrypt holds ~64 MiB per
in-flight passphrase decrypt.
This is a portfolio-ready educational project, not a complete production security product. Review the threat model before storing real sensitive photos.