Share markdown that keeps quiet.
End-to-end encrypted markdown sharing with expiring links. No accounts, no tracking, nothing readable on the server.
hush.md
Write a markdown document in a split-pane editor, hit Share, and get two links — an edit link and a read-only view link. The document is encrypted in your browser before anything leaves it, and it deletes itself on the schedule you pick.
- End-to-end encrypted — AES-256-GCM in the browser via WebCrypto. The key travels in the URL fragment (
#k=…), which browsers never send to servers. - Expiring links — presets from 1 hour to 30 days, or any custom duration from 1 minute to 90 days. Expiry means deleted from disk, enforced on read, at boot, and by a background sweep.
- View limits — let a document self-destruct after N views (preset or custom). The Nth reader gets it; the N+1th gets nothing.
- Password protection — an optional password is mixed into the key derivation (PBKDF2-SHA-256, 600k iterations), so the link alone isn't enough. There is no server-side "has password" flag: protected and plain documents are indistinguishable to the server.
- Burn-once secrets — drop an API key or password into a document as a
{{secret}}; it's encrypted with its own fresh key and can be revealed exactly once. Everyone after sees only when it was taken. - Edit links vs view links — the edit capability is a separate token, enforced server-side, not a hidden button.
- Live split-pane editor — CodeMirror 6 + GFM preview (tables, task lists, code), light/dark themes, editor/split/preview layouts. Drag the divider to rebalance the panes, and the two scroll together so the rendered result keeps pace with the source.
- Maths and diagrams — LaTeX via KaTeX (
$inline$,$$display$$) and Mermaid diagrams from```mermaidfences, both rendered in your browser from the same encrypted text. Neither renderer is in the initial bundle; they load only when a document uses them. - Callouts — Obsidian-style
> [!warning] Titleblocks across all thirteen kinds, each with its own icon. - Document outline — a collapsible panel of the document's headings, click to jump. Read-only readers get it too, which is where a long shared document needs it most.
.mddownload — from the editor before sharing too, so "keep it entirely local" is a first-class path.- Self-contained HTML export — one file with fonts, diagrams and maths embedded and every dead control stripped. No reference back to hush.md, so it opens with no network.
- QR codes — optional, per link, generated locally. Pairs with short links: a full link makes a 41-module code, a shortened one 29.
- No accounts — a document and its link are the entire relationship.
The server is a blind relay and blob store. What it stores per document:
| Stored | Meaning |
|---|---|
| ciphertext snapshot | your document, AES-256-GCM encrypted client-side |
| wrapped content key | the doc key, itself encrypted with a key derived (PBKDF2-SHA-256, 600k iterations) from the link key |
| KDF salt | public |
| SHA-256 of the edit token | lets the server verify editors without being able to become one |
| creation/expiry timestamps, view counters | scheduling metadata |
| burn-once secret blobs | each encrypted client-side with its own key (carried inside the encrypted document, never sent); burned rows keep only a timestamp |
The link key and content key exist only in URL fragments and browser memory. The server cannot read a document, and a database leak yields ciphertext plus hashes.
What the server does see (honesty section): request IPs and timing, document sizes, and view counts. Request logs redact document IDs.
The standard web-E2E caveat applies: the encryption is only as trustworthy as the JavaScript this server delivers. If your threat model includes the operator, self-host it — the code you're reading is the code that ships.
Hardening in place: strict CSP, HSTS, per-IP rate limits, storage budget guard, timing-safe token comparison, DOMPurify over markdown-it (html: false) as the XSS boundary.
- Write — the document lives in your browser. Nothing is sent while you type.
- Share — the client generates a random content key + link key + edit token, encrypts the document, wraps the content key, and uploads only ciphertext. The link it hands you carries the keys in the fragment.
- Open — a recipient's browser fetches the ciphertext, derives the wrapping key from the fragment, unwraps, decrypts, renders. Wrong link ⇒ decryption fails; there is no password-reset because there is nothing to reset.
- Hush — at expiry the ciphertext is deleted. Links to deleted documents are indistinguishable from links that never existed.
Everything runs from one small container: Node + Fastify + SQLite, with the built client served statically.
docker build -t hush-md .
docker run -p 8080:8080 -v hush_data:/data hush-mdEnvironment: PORT (default 8080), DATA_DIR (default /data), STATIC_DIR (set in the image). A fly.toml is included — the public instance runs on a single 256 MB scale-to-zero Fly.io machine.
npm workspaces monorepo:
packages/envelope shared crypto (browser + Node, WebCrypto only)
packages/server Fastify blind blob store + SQLite
packages/web Vite + CodeMirror client
npm install
npm test # vitest, all packages
npm run dev:server # API on :8080
npm run dev:web # Vite dev server, proxies /apiThe crypto envelope is implemented exactly once and imported by every consumer — a deliberate rule to avoid E2E implementations drifting apart.
Live collaboration (encrypted CRDT relay) and a CLI for sharing from the terminal. Everything else listed above is already live. Standing rule: if a feature would require the server to read your words, it doesn't ship.
One consequence worth stating plainly: rendering happens in your browser, so a diagram or a formula never leaves it. That is also why Mermaid output is sanitized a second time on the way into the page — a diagram is someone else's content, and strict mode alone isn't the only lock worth having.