Your server stores opaque blobs. Your browser holds the keys. Single Go binary. No Docker, no Postgres, no Redis, no S3. Files are encrypted and decrypted in the browser, in memory — the server never receives a plaintext file.
data/
darkreel.db # rows of ciphertext
f47ac10b-58cc/
a3d9c8e2-7b14/
000000.enc [4.00 MB] # could be anything
000001.enc [2.00 MB] # exact bucket size
000002.enc [1.00 MB] # padding is inside the ciphertext
thumb.enc [256 KB] # every thumbnail, same size
The browser pads each chunk before encrypting it, so every chunk’s ciphertext is
exactly 1, 2, 4, 8, or 16 MB and every thumbnail exactly 256 KB — on the wire and on disk.
File and directory modification times all read 2024-01-01T00:00:00Z. Upload dates are
coarsened to year only (delegation records use date granularity).
An attacker with root on your server sees uniform blobs with no meaningful metadata.
| data | visible to server |
|---|---|
| file content | no - AES-256-GCM, per-file key |
| file names | no - encrypted metadata blob |
| file types / MIME | no - encrypted metadata blob |
| file sizes | no - padded inside the encryption; only bucket sizes + chunk count |
| dimensions / duration | no - encrypted metadata blob |
| thumbnails | no - separate encrypted key |
| folder structure | no - padded, encrypted blob |
| passwords | not stored - Argon2id hash only; sent over TLS at login, where the KDF runs (see note) |
| master key | not stored - encrypted at rest; in server memory only during login, password change or recovery, then cleared (see note) |
| usernames | yes |
| file count per user | yes - database row count |
| total storage | ~approx - sum of padded bucket sizes (what quota is charged on) |
| upload time | year only - coarsened |
| delegation authorize / last used | date only - coarsened |
What “zero-knowledge” means here — and where it stops. The table describes what the server holds at rest: a stolen database, disk image, or backup yields only Argon2id hashes and ciphertext. That property is real, and it is the threat model Darkreel is built for. It is not the same as the server never seeing the material. Web Crypto has no Argon2id, so the browser cannot run the KDF — the password is sent over TLS and the server performs the derivation (the same work described under concurrent login throughput below). During that request the server necessarily holds your password and, briefly, your decrypted master key, which is cleared as soon as it has been re-wrapped for the client. Registration generates the master key and X25519 keypair server-side before wrapping them, and a password change or recovery runs server-side too: it generates fresh keys and re-seals every item’s keys to them, so for that one request the server holds the old and new keys. A passive operator, a database thief, or anyone with the disk afterwards learns nothing. An operator running modified server code can capture the password of anyone who logs in — the mitigation being to run a binary you built yourself, which is the intended deployment anyway.
Once login completes, the server has cleared its copy and the keys live in the browser. It
unwraps the master key straight into non-extractable CryptoKeys and imports the
private key the same way — once imported, even XSS can’t read their bytes, only use them
while the tab is signed in. Both master key and private key are also wrapped by a 256-bit recovery code.
A password change or recovery replaces the master key, keypair and recovery code together and
re-seals every item to the new public key.
Uploads (browser, CLI, or delegated third-party) share one wire format. Delegated clients hold only the public key — they can seal uploads to the user but cannot open anything. This lets other apps upload to your account without ever seeing a password or an AES key.
All block-level encryption uses Additional Authenticated Data (AAD) to bind ciphertext to its context. An attacker with database access cannot swap encrypted keys or metadata between users or media items - decryption fails if the AAD doesn't match.
| password hashing | Argon2id - 3 iterations, 64 MB, 4 threads |
| master key derivation | Argon2id - separate salt from auth hash |
| file / thumb / metadata encryption | AES-256-GCM - media ID + chunk index as AAD (prevents reordering and cross-file substitution) |
| per-file key wrapping | X25519 + HKDF-SHA256 + AES-256-GCM - sealed-box to user's public key, 92-byte output per 32-byte key |
| owner tag | HMAC-SHA256 under HKDF-SHA256(master key, "darkreel-owner-v1") over media ID + the three sealed keys, stored in the encrypted metadata |
| user keypair | X25519 - private key dual-wrapped (master key + recovery code) |
| session key | PBKDF2-SHA256 - 600,000 iterations |
| chunk padding | inside the AEAD: plaintext framed and zero-padded so the ciphertext is exactly 1 / 2 / 4 / 8 / 16 MB (then whole MB, max 20 MB); thumbnails exactly 256 KB |
| truncation detection | authenticated last-chunk flag in every chunk frame + chunk count in encrypted metadata |
| metadata padding | metadata and folder tree ASCII-space-filled to power-of-2 from 512 B before encryption (JSON-parseable without an unpad step) |
| delegation tokens | HS256 JWT, scope=upload, 1 h TTL, bound to its delegation and checked on every request — rejected on every non-upload endpoint |
| refresh tokens | 32-byte URL-safe random, stored as sha256("darkreel:delegation-refresh-v1|"‖token); expire after 60 days unused or 1 year |
| hash modification | nonce injection - JPEG COM, PNG tEXt; CLI also MP4 free box (appended at end) for videos it doesn't remux |
| deletion | 1-pass random overwrite → fsync → unlink (keys deleted first) |
// chunk plaintext (format 2) - framed and padded before encryption
[version: 1B] [flags: 1B, last chunk] [length: 4B] [data] [zero padding]
// encrypted chunk - exactly one bucket size
[nonce: 12 bytes] [ciphertext] [GCM tag: 16 bytes]
// chunk index bound as AAD - reorder and decryption fails
// on disk
[length: 4B big-endian] [encrypted chunk] [random fill]
// the length prefix only ever reveals the bucket
Videos are remuxed to fragmented MP4 on upload - no re-encoding. The CLI uses ffmpeg (handles everything: WEBM, MKV, AVI, etc.). The browser uses mp4box.js (144 KB, no WASM, handles MP4/MOV).
upload:
container → extract samples → fMP4 fragments (1-2 s)
→ merge into ≤1 MB chunks → frame + pad to bucket
→ AES-256-GCM encrypt → upload
playback:
fetch chunk (prefetch-ahead) → Web Worker decrypt
→ MediaSource Extensions → <video>
→ playback starts after first chunk
download:
fetch all chunks → decrypt → check last-chunk flag
→ fMP4 → standard MP4
iOS Safari 17.1+ uses ManagedMediaSource. Non-remuxable formats uploaded via browser are stored as-is and played via blob URL.
MP4 MOV WEBM MKV M4V JPG PNG GIF WEBP
MP4 and MOV get streaming playback in the browser. CLI uploads via ffmpeg support all video formats with full streaming.
Any other file type can also be uploaded and stored with full encryption - no preview, just encrypted storage and download.
Every chunk is rounded up to its bucket, so a 3.2 MB photo is stored and sent as four 1 MB chunks, and a video’s chunks (which follow fragment boundaries) each carry some slack. Thumbnails are always 256 KB regardless of actual size. This is the cost of preventing size fingerprinting on both storage and network layers.
Upload dates are stored as year only; delegation authorize / last-used dates keep date granularity so the Connected Apps panel can still surface an app that is active when it shouldn’t be. Precise timestamps reveal usage patterns. That precision is deliberately discarded.
The server can't see your files, so it can't generate thumbnails. The browser encrypts them before upload with a separate per-file key.
Lose your password and your recovery code? Your data is cryptographically gone. No backdoor, no admin recovery, no "forgot password" email. This is correct behavior for a zero-knowledge system.
The overwrite pass works on HDDs. On SSDs, wear leveling may retain old data. Since encryption keys are deleted before shredding, the ciphertext is computationally unrecoverable regardless. LUKS full-disk encryption on the data partition provides an additional layer — see hardening.
Quotas are charged on what actually lands on disk: every padded chunk bucket (1/2/4/8/16 MB) plus a 256 KB thumbnail per item, enforced while the chunks stream in. A 1 GB quota therefore holds somewhat less than 1 GB of files. The server never learns exact sizes, so it can’t charge for them.
With PERSIST_SESSION=true (the default), the keys are kept in IndexedDB so
page refreshes don't require re-login — as non-extractable CryptoKeys, never
raw bytes. Script running in the page (XSS, a malicious extension) still can’t read them, but
could use them while the session lasts; logout or 30 minutes idle clears them. Set
PERSIST_SESSION=false to keep the keys in memory only.
Each login performs two Argon2id derivations (~600 ms total, 64 MB each). A global gate lets only 2–4 such requests (login, register, recovery, password change) run at once; beyond that, requests wait briefly and then get a 503 “try again”. This is a deliberate trade-off that keeps a login burst from exhausting RAM on a small VPS.
Darkreel does not support horizontal scaling or clustering. A single machine with adequate disk is sufficient for most self-hosted use cases.
One writer at a time. Rarely a practical bottleneck since chunk I/O doesn't hold the database lock.
The number of chunks per file is sent unencrypted during upload so the server can validate upload completeness. Together with the bucket sizes, this reveals approximate file size (to within about 1 MB per chunk). Exact sizes remain hidden by the in-ciphertext padding and encrypted metadata.
git clone https://github.com/baileywjohnson/darkreel.git && cd darkreel
less setup.sh # read it first - it runs as root
sudo ./setup.sh
# firewall, fail2ban, SSH hardening, TLS via Caddy,
# systemd service, encrypted daily backups - all handled
Designed for a fresh Ubuntu 22.04+ or Debian 12+ VPS. The script asks for your domain (verified against server IP), an admin password, a per-user quota, a backup encryption key, and optionally a personal SSH user and auto-updates. It installs a checksum-verified Go toolchain and builds from source — nothing is piped from the internet into a shell. Safe to re-run.
$ git clone https://github.com/baileywjohnson/darkreel.git && cd darkreel
$ bash build.sh
$ DARKREEL_ADMIN_PASSWORD='YourStr0ng!Password' ./darkreel
Darkreel listening on 127.0.0.1:8080
# put Caddy or nginx in front for TLS
Darkreel handles 100K+ media items without issue. Chunks stream with zero-copy I/O. Startup is parallelized (integrity checks, cleanup, and migration run concurrently). Streaming uploads use ~64 KB memory per chunk write regardless of file size, so uploading a 10 GB file costs the same RAM as a 10 MB file.
$ ./darkreel -addr 127.0.0.1:8080 -data ./data # defaults
# Environment variables
DARKREEL_ADMIN_USERNAME=admin # first-run only
DARKREEL_ADMIN_PASSWORD=... # required on first run
PERSIST_SESSION=true # keep non-extractable keys across refresh (default: on)
ALLOW_REGISTRATION=false # initial state; admin panel toggle persists to DB
TRUST_PROXY=false # enable behind Caddy/nginx for correct rate limiting
TRUST_PROXY_CIDR=127.0.0.1/32,::1/128 # only honor X-Forwarded-For from these proxies
MAX_STORAGE_GB=50 # per-user storage quota in GB (default: 1 GB if not set)
Everything except /health, /api/config, register/login/recover and the
delegation exchange/refresh endpoints requires a JWT. JWTs contain user ID, session ID, and admin
flag; delegation tokens carry scope=upload and their delegation ID instead. An
account created by an admin gets 403 password change required everywhere except
change-password and logout until it picks its own password.
The database is load-bearing. Every encrypted file key lives in
darkreel.db. Lose the database and every file on disk becomes permanently
undecryptable - even with the correct password.
# hot backup (server stays running, WAL-safe)
$ sqlite3 /var/lib/darkreel/darkreel.db ".backup /path/to/backup.db"
# full backup (stop for consistency)
$ sudo systemctl stop darkreel
$ tar czf darkreel-backup.tar.gz /var/lib/darkreel/
$ sudo systemctl start darkreel
The setup script configures a nightly database backup: a SQL dump streamed straight into
age, encrypted to a public key whose private half is shown once and never kept on the
server, stored root-only in /var/backups/darkreel with 30-day retention. Backups are
safe to store off-site - media is encrypted on disk, and database backups are additionally
encrypted to a key the server doesn’t have. Even decrypted, a backup holds only hashes and
wrapped keys: nothing opens without a user’s password or recovery code.
Migrations run automatically on startup.
$ cd darkreel && git pull && bash build.sh # your clone
$ sudo cp darkreel /usr/local/bin/darkreel
$ sudo systemctl restart darkreel
# or use the auto-updater: installs tagged releases only if the Ed25519 signature
# over (tag, asset, SHA-256) verifies against /etc/darkreel/signing.pub; refuses
# unsigned binaries and downgrades
$ sudo ./update.sh --install # daily cron at 4 AM
The setup script handles all of this automatically. Checklist for deploying manually:
TLS termination Caddy or nginx - darkreel doesn't handle TLS
UFW firewall SSH, HTTP, HTTPS only
fail2ban auto-ban after failed SSH attempts
SSH hardening root login disabled (once a personal SSH user exists)
systemd sandbox 15+ directives: capabilities, syscall filter, namespaces, and more
dedicated user darkreel user with minimal permissions
SRI hashes page JS/CSS integrity-verified, incl. mp4box and the crypto.js module app.js imports
rate limiting 5 auth/min/IP (IPv6 per /64) + 10 failures/15min/account, login and recovery counted separately
Argon2 gate 2-4 concurrent KDF requests server-wide, 503 when saturated - no memory exhaustion
security headers CSP, HSTS, Permissions-Policy, Cache-Control: no-store
COOP/COEP defense-in-depth for SharedArrayBuffer
graceful shutdown drains in-flight requests and pending shreds before closing database
session expiry 24h max, periodic cleanup of stale sessions; 30 min idle lock in the browser
password change keys rotated; all sessions and connected apps revoked immediately
forced password change admin-created accounts can do nothing else until they set their own password
delegation expiry connected apps expire after 60 days unused / 1 year; tokens checked against the delegation on every request
admin re-check admin status verified from DB on every admin request
timing mitigation unknown usernames take the same time as wrong credentials on login and recovery
BREACH mitigation compression disabled on auth endpoints that return secrets
last-admin guard atomic transaction prevents TOCTOU race on admin deletion
storage quotas mandatory per-user limits in GB (byte-accurate), raise-only, validated against disk (2 GB reserve)
secure delete (DB) PRAGMA secure_delete verified at boot; WAL truncated periodically and after key changes or account deletion
hashed identifiers IPs and usernames keyed-hashed (SipHash, per-process random seed) - no plaintext in dumps, no collision attacks
privacy-safe logs no usernames, user IDs, media IDs, or IP addresses in server logs
path validation UUID validation at storage layer - defense-in-depth against path traversal
proxy-aware IPs X-Forwarded-For off by default; with TRUST_PROXY, rightmost untrusted hop only, X-Real-IP ignored
startup integrity incomplete uploads cleaned up on restart, crashed size records backfilled
encrypted backups age-encrypted to an offline key, root-only, 30-day retention
upload concurrency max 3 concurrent uploads per user - prevents disk exhaustion
network padding padding is inside the ciphertext - same bucket sizes on the wire as on disk
master key clearing cleared from server memory as soon as it is re-wrapped for the client
recovery rotation recovery code replaced on every password change and recovery
signed updates Ed25519 signature bound to tag + asset + SHA-256; no downgrades; hard failure on missing key
access log control setup offers to disable Caddy access logs for privacy
thumbnail validation oversized thumbnails rejected with clear error, not silently truncated
folder tree padding padded before encryption, then again with random bytes on the server - prevents DB-level size inference
thread-safe PRNG per-goroutine ChaCha8 instances for padding and shredding - no shared mutable state
shredder shutdown rejects new work after shutdown begins - prevents panics during graceful shutdown
metadata update limits PATCH endpoint enforces same size limits as upload (64 KB metadata, 64B nonces)
dynamic cache-bust dynamically loaded scripts include content-hash params - prevents stale cache after upgrades
generic reg errors public registration returns generic error on failure - prevents username enumeration
admin storage coarse per-user used_bytes coarsened to nearest GB - reduces per-upload activity monitoring
The secure deletion overwrite is defense-in-depth — encryption keys are deleted first, making the ciphertext computationally unrecoverable. The overwrite is unreliable on SSDs due to wear leveling. If your threat model includes physical disk seizure, encrypt the data partition:
# set up LUKS before installing Darkreel
$ sudo cryptsetup luksFormat /dev/sdX
$ sudo cryptsetup open /dev/sdX darkreel-data
$ sudo mkfs.ext4 /dev/mapper/darkreel-data
$ sudo mount /dev/mapper/darkreel-data /var/lib/darkreel
With LUKS, all data at rest is encrypted at the block level. The combination of Darkreel’s application-layer encryption (keys deleted before shredding) and LUKS block-layer encryption provides two independent layers of protection against physical recovery. Recommended for production VPS deployments.
PERSIST_SESSION (default: true) controls whether the browser keeps
its keys in IndexedDB — as non-extractable CryptoKey objects, never raw bytes
— so they survive a page refresh.
PERSIST_SESSION=true keys survive refresh (default)
page script can't read them, but XSS or a malicious
extension could use them until logout / 30 min idle
PERSIST_SESSION=false keys only in JS memory, cleared on refresh
users re-enter password on every page load
more secure, less convenient
To disable: set PERSIST_SESSION=false in /etc/darkreel/env
and restart the service.