Zero-knowledge URL shortener. The destination URL is encrypted in your browser before it leaves; the server stores only opaque ciphertext. The decryption key lives in the URL fragment, which the browser never transmits to any server.
https://voidhop.com/aBcD1234#dW5BUlhwbXFBLW8xUlBxNHpyN1AwYUw3WkpqTjBabXM
^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
link ID AES-256 key — never sent to the server
Optional: a per-link password adds a second factor. The salt joins the fragment after a .; the server holds only an opaque verifier:
https://voidhop.com/aBcD1234#<key>.<salt>
^^^^^^ salt, also stays in the browser
If our server is compromised, subpoenaed, or curious, the only thing it can produce is a pile of opaque ciphertexts.
- AES-256-GCM client-side encryption (browser-native Web Crypto, no npm crypto deps)
- Length-prefix padding to 5 size buckets — observers see at most one of 5 discrete sizes, never the exact length
- 3 TTL options (1h / 24h / 7d) — 7 days is the universal maximum, bounding storage to a self-healing 7-day rolling window
- Per-IP rate limiting with IPv6 /64 subnet bucketing
- Per-origin daily write sub-quotas (CORS allowlist + containment of allowed-origin compromise)
- No analytics, no tracking, no click counters, no referrer logging
- Light/dark theme
- Built for the Cloudflare free tier — 100 % zero hosting cost at expected volumes
- Password protection. PBKDF2-SHA256 (600 000 iterations) + HKDF-SHA256 derive an encryption key from
fragment_key ‖ password. Salt stays in the URL fragment; server stores only a verifier. Five wrong attempts destroy the record. Each wrong attempt triggers an exponential backoff (1 s → 5 s → 30 s → 120 s) enforced server-side so even the correct password is rejected during the cool-down window. - Multi-use counter. Opt in to "expires after N reads" (1, 3, or 10).
N = 1is classic burn-after-reading. The server decrements atomically and deletes on zero. - Creator deletion token. Opt in to receive a second URL (
/delete/<id>#<token>) that lets you destroy the link on demand before its TTL expires. The server stores onlySHA-256(token); the raw token never leaves the browser that created it.
- Commit SHA footer. Every page shows the git commit hash of the running build, linking to the exact public source commit. You can verify the code your browser is running matches the audited repository.
- Strict CSP + HSTS via
public/_headers(Cloudflare Pages) and mirrored in the Worker's security-headers middleware.script-src 'self'andconnect-src 'self'prevent exfiltration channels even in the presence of an XSS bug. - Post-build integrity audit.
scripts/check-no-external-deps.mjsfails the build ifdist/index.htmlreferences any cross-origin resource without an SRIintegrity=attribute. Keeps the dependency graph honest even under future refactors.
The full spec, threat model, and design rationale live in docs/voidhop-SRS-v2.md.
npm install
npm run dev # starts both the Vite frontend and the Wrangler workernpm run dev is a small Node script (scripts/dev.mjs) that:
- Picks the first free port at or after 5173 for Vite
- Picks the first free port at or after 8787 for the Wrangler worker
- Launches both processes in parallel with prefixed, colored output (
vitein cyan,workerin magenta) - Tells Vite about the worker's chosen port via the
WORKER_DEV_PORTenv var so the/api/*proxy points to the right place - Cleans both up on
Ctrl+C
This means you can run several VoidHop checkouts in parallel — or run VoidHop alongside other Workers projects — without any port collisions or manual coordination.
If you ever need to run the two processes in separate terminals (for log filtering, independent restarts, etc.), use the split scripts:
npm run dev:vite # Vite only — proxies /api to WORKER_DEV_PORT (default 8787)
npm run dev:worker # Wrangler worker only — picks its own port via --portNote that the split scripts do not auto-coordinate ports; you have to set WORKER_DEV_PORT yourself if you give the worker a non-default port.
Local build + lint + typecheck:
npm run build # tsc -b, vite build, integrity audit — outputs ./dist
npm run typecheck
npm run lintTopology: Cloudflare Pages serves the static SPA from dist/; Cloudflare Worker serves the /api/* routes. Same-origin, no CORS.
- Create the KV namespace in the dashboard: Storage & Databases → KV → Create a namespace. Paste the ID into
wrangler.toml(replacingREPLACE_WITH_PRODUCTION_NAMESPACE_ID). - Connect Workers & Pages → Create → Workers → Import a repository to this repo. Build command:
npm install. Deploy command:npx wrangler deploy. The[[routes]]block inwrangler.tomlbinds the Worker tovoidhop.com/api/*automatically on first deploy. - Connect Workers & Pages → Create → Pages → Connect to Git to the same repo. Framework preset:
None. Build command:npm install && npm run build. Build output directory:dist. - In the Pages project: Custom domains → Set up a custom domain → your domain. CF wires the DNS automatically if the zone is already on your account.
After first deploy, commits to main auto-build both the Worker and the Pages site. Rollback from each project's Deployments list.
See docs/SELF-HOSTING.md for the long version including offline/local builds.
npm test # unit + integration (Vitest + Miniflare)
npm run test:e2e # end-to-end (Playwright)Security-critical paths covered by tests:
- AES-256-GCM round-trip across all bucket sizes
- Length-prefix padding boundary cases (incl. the v1.0
0x01delimiter footgun) - IPv6 /64 subnet collapse for rate limiting
- TTL ceiling enforcement on POST
- 400 → 404 collapse on GET/HEAD/DELETE for invalid ID format
Cache-Control: no-store, no-cacheon every status code (incl. error responses)
- The server never sees the destination URL.
- The server never sees the AES key.
- VoidHop logs no clicks, no IPs (beyond Cloudflare's own transient logging), no referrers.
- The about page (
/about) explains the architecture and the honest limitations in plain language.
For the full threat model and the things VoidHop deliberately does not defend against, see SRS §3.
MIT — see LICENSE.