source

🍿darkreel

encrypted media storage & streaming

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.


what the server sees

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.

datavisible to server
file contentno - AES-256-GCM, per-file key
file namesno - encrypted metadata blob
file types / MIMEno - encrypted metadata blob
file sizesno - padded inside the encryption; only bucket sizes + chunk count
dimensions / durationno - encrypted metadata blob
thumbnailsno - separate encrypted key
folder structureno - padded, encrypted blob
passwordsnot stored - Argon2id hash only; sent over TLS at login, where the KDF runs (see note)
master keynot stored - encrypted at rest; in server memory only during login, password change or recovery, then cleared (see note)
usernamesyes
file count per useryes - database row count
total storage~approx - sum of padded bucket sizes (what quota is charged on)
upload timeyear only - coarsened
delegation authorize / last useddate 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.

what you get

end-to-end encryption
AES-256-GCM everything. Fresh per-file keys, sealed to your own X25519 public key. The server is a dumb blob store.
zero-knowledge metadata
File names, types, sizes, dimensions, durations - encrypted into a single blob per file, padded first so its length doesn't give away a name's length. The server can't read any of it.
encrypted streaming
Videos stream via MSE with chunk-level decryption in a Web Worker. No server-side decryption. Starts after one chunk.
size fingerprinting resistance
Padding happens inside the encryption: 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. Exact chunk lengths — a strong fingerprint for known videos — never leave the browser.
secure deletion
Random overwrite, fsync, then unlink. Keys deleted first — ciphertext is unrecoverable regardless. Best-effort on SSDs due to wear leveling.
multi-user
Isolated encrypted libraries. Each user gets their own master key. Admin panel for user management; accounts an admin creates must choose their own password at first sign-in, which rotates away the keys the admin could have unlocked.
hash modification
A random nonce is written into images before encryption (JPEG COM marker, PNG tEXt chunk; the CLI also appends an MP4 free box to videos it doesn't remux), so a downloaded copy doesn't hash-match the original. The nonce is never sent to the server.
truncation detection
The chunk count lives in the encrypted metadata and every chunk carries an authenticated last-chunk flag. On download and playback the client checks both — a server that drops the tail of a file gets an error, not a silently shortened video.
generic file storage
Not just media. Upload any file type - PDFs, documents, archives, code. Same zero-knowledge encryption.
encrypted folders
Organize your files into folders. The structure is encrypted - only you can see it. Drag-and-drop to reorganize on desktop and mobile.
folder download
Download an entire folder (including subfolders) as a ZIP file, decrypted client-side. No server involvement.
image rotation
Rotate images at the pixel level. The original is securely deleted and replaced with a freshly encrypted copy using new keys.
text editor
Plain-text files (txt, md, log, csv, tsv, json, yaml, xml, ini, conf, cfg) open in an in-browser editor. Edit and save — a fresh copy is encrypted and the old one is securely deleted. Create new text documents from the Upload modal.
6 color themes
Classic, cool, forest, neon, ocean, and warm. Stored in localStorage.
recovery codes
256-bit code generated at account creation and replaced on every password change or recovery. If you lose your password, this is the only way back in. Lose both and your data is gone.
key rotation
A password change or recovery doesn't just re-wrap the old keys: it generates a new master key, keypair and recovery code, re-seals every item's keys in one transaction, and signs out every session and connected app. An old database copy plus the old password opens nothing uploaded afterwards (earlier items keep their file keys).
owner verification
Your own uploads carry a tag only your master key can produce. Anything without a valid one — uploads from a connected app, or rows planted directly in the database — is marked with an APP badge. Renaming or moving an item adopts it.
non-extractable keys + idle lock
In the browser, the master key and private key are held as non-extractable Web Crypto keys — page script can use them but can’t read their bytes. After 30 minutes without activity (a playing video counts), the tab signs out and drops them.
delegated uploads
Grant other apps (e.g., PPVDA) upload-only access via a copy-paste consent flow. They hold your X25519 public key and a refresh token — they can seal uploads to you but can't read, list, or delete anything. Connections expire after 60 days unused or a year at most, and revoking one from Settings → Connected Apps takes effect on the app's next request.
single binary
Go + embedded SQLite + embedded web UI. No runtime dependencies. No containers. No external services.
storage quotas
Per-user quotas in GB (default: 1 GB), charged on the padded bytes actually written to disk and enforced while chunks stream in. Per-user quotas can only be raised. Validated against available disk capacity with a 2 GB reserve.
self-hosted
Runs on your hardware. A $6/month VPS is enough. Your data never touches a third-party service.
async secure deletion
File shredding runs in a background worker pool so API responses return immediately while data is securely erased.
static asset caching
Content-hash cache busting for static assets. Only versioned URLs are cached long-term; everything else revalidates, so security fixes reach returning browsers.
upload progress tracking
Real-time progress bar on gallery tiles during upload. Encryption progress (0-50%) and network transfer progress (50-100%) via XHR upload events.
streaming chunk uploads
Chunks are written to disk as they stream in. ~64 KB memory per chunk write, regardless of chunk size.
rate-limited sensitive endpoints
Password change and account deletion are rate-limited per-IP (5/min); password change also counts failed attempts per account (10/15min), preventing brute-force of the old password via a stolen session.

key hierarchy

Password sent over TLS; the KDF runs on the server ├─ Argon2id(password, authSalt) → password hash login verification └─ Argon2id(password, kdfSalt) → KDF key └─ AES-256-GCM decrypt (AAD: userID) → master key server memory for this request only └─ re-wrapped under PBKDF2(password, kdfSalt) → browser non-extractable CryptoKey ├─ AES-256-GCM unwrap (AAD: userID) → X25519 private key non-extractable ├─ HKDF-SHA256 ("darkreel-owner-v1") → owner-tag key HMAC-SHA256 └─ encrypts folder structure (AAD: userID) X25519 keypair per user, generated at registration ├─ public key stored plaintext, served to browsers and delegated clients └─ private key dual-wrapped (master key + recovery code) Per-file keys one trio generated per upload ├─ file key → AES-256-GCM encrypts chunks (AAD: mediaID || chunkIndex) ├─ thumb key → AES-256-GCM encrypts thumbnail (AAD: mediaID || 0) └─ metadata key → AES-256-GCM encrypts metadata (AAD: mediaID) all three sealed to the user's public key via X25519 + HKDF-SHA256 + AES-256-GCM (info: "darkreel-seal-v1")

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.

algorithms

password hashingArgon2id - 3 iterations, 64 MB, 4 threads
master key derivationArgon2id - separate salt from auth hash
file / thumb / metadata encryptionAES-256-GCM - media ID + chunk index as AAD (prevents reordering and cross-file substitution)
per-file key wrappingX25519 + HKDF-SHA256 + AES-256-GCM - sealed-box to user's public key, 92-byte output per 32-byte key
owner tagHMAC-SHA256 under HKDF-SHA256(master key, "darkreel-owner-v1") over media ID + the three sealed keys, stored in the encrypted metadata
user keypairX25519 - private key dual-wrapped (master key + recovery code)
session keyPBKDF2-SHA256 - 600,000 iterations
chunk paddinginside 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 detectionauthenticated last-chunk flag in every chunk frame + chunk count in encrypted metadata
metadata paddingmetadata and folder tree ASCII-space-filled to power-of-2 from 512 B before encryption (JSON-parseable without an unpad step)
delegation tokensHS256 JWT, scope=upload, 1 h TTL, bound to its delegation and checked on every request — rejected on every non-upload endpoint
refresh tokens32-byte URL-safe random, stored as sha256("darkreel:delegation-refresh-v1|"‖token); expire after 60 days unused or 1 year
hash modificationnonce injection - JPEG COM, PNG tEXt; CLI also MP4 free box (appended at end) for videos it doesn't remux
deletion1-pass random overwrite → fsync → unlink (keys deleted first)

on-disk format

// 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

encrypted video playback

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.

media formats

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.

choices

chunk padding wastes disk space and bandwidth

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.

timestamps are coarsened

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.

no server-side thumbnails

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.

no recovery without codes

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.

SSD deletion is best-effort

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 count the padding

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.

session persistence is a trade-off

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.

deliberate constraints

concurrent login throughput

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.

single-machine architecture

Darkreel does not support horizontal scaling or clustering. A single machine with adequate disk is sufficient for most self-hosted use cases.

SQLite write contention

One writer at a time. Rarely a practical bottleneck since chunk I/O doesn't hold the database lock.

chunk count sent in plaintext

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.

one command on a fresh VPS

full setup
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.

manual

$ 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
~14MB
RAM usage
1
binary
0
dependencies
$6/mo
min. VPS cost

scalability

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.

configuration

$ ./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)

endpoints

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.

auth
POST/api/auth/registerregister, returns recovery code
POST/api/auth/loginreturns JWT + wrapped master key (+ must_change_password)
POST/api/auth/logoutimmediate session invalidation
POST/api/auth/recoverreset password with recovery code, rotates all keys
POST/api/auth/change-passwordnew master key, keypair + recovery code; re-seals every item; revokes sessions + apps
DELETE/api/auth/accountdelete account and all media
GET/api/configserver config (no auth)
media
GET/api/medialist media (paginated)
GET/api/media/quotacheck quota (effective quota + current usage)
GET/api/media/:idget metadata
POST/api/media/uploadmultipart: metadata + thumbnail + chunks
PATCH/api/media/:idupdate metadata
DELETE/api/media/:idsecure delete (1-pass shred)
GET/api/media/:id/chunk/:idxdownload encrypted chunk
GET/api/media/:id/thumbnaildownload encrypted thumbnail
folders
GET/api/foldersget encrypted folder tree
PUT/api/folderssave encrypted folder tree
delegation (connected apps)
POST/api/delegation/authorizeissue a one-use code (2 min)
POST/api/delegation/exchangecode → refresh token + public key (no JWT)
POST/api/delegation/refreshrefresh token → 1 h upload-only token (no JWT)
GET/api/account/delegationslist connected apps, with expiry dates
DELETE/api/account/delegations/:idrevoke - effective on the app's next request
admin
GET/api/admin/userslist users with storage usage
POST/api/admin/userscreate user (must change password at first login)
DELETE/api/admin/users/:iddelete user + all media
PATCH/api/admin/users/:id/quotaraise per-user storage quota (can only increase)
GET/api/admin/storagestorage stats (used bytes, allocated quota, disk usage)
PUT/api/admin/storage/quotaset default storage quota for new users
POST/api/admin/registrationtoggle registration
health
GET/health→ {"status":"ok"}

backups

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.

upgrading

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

system requirements

1
vCPU min
512MB
RAM min
10GB
disk min
amd64
or arm64

security hardening

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

disk encryption (LUKS)

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.

session persistence

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.