RAM-only service for self-destructing end-to-end encrypted notes.
The server stores only ciphertext — it never sees the key, never touches disk, and atomically destroys the note on first read. Zero configuration required for self-hosting.
- The browser encrypts the note with AES-256-GCM (WebCrypto). The key never leaves the client.
- The server receives only ciphertext and stores it in RAM — no disk writes.
- The key lives in the URL
#fragment, which browsers never include in HTTP requests. - The note is atomically deleted on the first read (or when its TTL expires / the process restarts).
The entire frontend is plain HTML + JS + CSS with no build step, no CDN dependencies, and no server-side rendering. You can:
- Download once, use anywhere — save
index.html,app.js,styles.css, andpow-worker.jsto a local folder and openindex.htmldirectly in any browser, including Tor Browser. - Point at any backend — use
?api=https://your-serverin the URL (or enter it in the settings panel) to connect the local frontend to any SecNote instance. - Distrust the server's frontend delivery — if you don't trust that the server is serving unmodified JS, audit the files once and use your own copy. The server never needs to touch your frontend again.
- Full functionality offline — encryption, PoW solving, QR code generation, and i18n all run locally. Only the API calls (
/api/v1/*) go to the network.
No apps to install, no packages to build, no runtime to configure. Works in air-gapped environments and high-privacy contexts where installing software is not an option.
| Property | Detail |
|---|---|
| Zero-knowledge server | Stores only nid, blob, and timestamps — never the key |
| Burn protection | Reading requires view_token = SHA-256(aes_key); knowing only nid is not enough |
| Payload-bound PoW | SHA-256(challenge ‖ nonce ‖ SHA-256(ttl ‖ blob)) — challenge can't be reused for a different payload |
| One-time challenge | Both challenge and note are consumed atomically |
| IP privacy | Anti-abuse state keyed on SHA-256(IP ‖ server_salt); raw IPs never stored |
| Ephemeral salt | server_salt is random per process start; all anti-abuse state lost on restart |
| Authenticated API responses | Every /api/v1/* and /info response is signed with an ephemeral Ed25519 key generated at startup; the client verifies the signature before parsing — a network-level attacker with a forged TLS cert cannot inject or replay responses |
| Strict CSP | default-src 'none' with minimal allowlist |
| Offline shell | Service worker caches static assets; API calls always bypass the cache |
Legend: ✅ Yes |
⚠️ Partial / optional | ❌ No
| Feature | SecNote | PrivateBin | Cryptgeon | Yopass | One-Time Secret |
|---|---|---|---|---|---|
| Backend language | Rust | PHP | Rust + TS | Go | Ruby |
| Storage | RAM only | Filesystem / DB | Redis / RAM | Memcached / Redis | Redis |
| External service required | ✅ None | ✅ None | ❌ Required | ❌ Required | |
| Client-side encryption | ✅ | ✅ | ✅ | ✅ | ❌ Server-side |
| Zero-knowledge server | ✅ | ✅ | ✅ | ✅ | ❌ |
| Burn after read | ✅ | ✅ | ✅ | ✅ | ✅ |
| Authenticated API responses | ✅ Ed25519 per-response | ❌ | ❌ | ❌ | ❌ |
| Anti-spam / bot protection | ✅ Proof-of-Work | ❌ | ❌ | ❌ | |
| Burn-read protection¹ | ✅ view_token |
❌ | ❌ | ❌ | ❌ |
| IP privacy by design | ✅ Hashed | ❌ Raw IPs | ❌ Raw IPs | ❌ Raw IPs | ❌ Raw IPs |
| Built-in TLS | ✅ Rustls | ❌ Web server | ❌ Proxy | ❌ Proxy | ❌ Proxy |
| PWA + offline support | ✅ | ❌ | ❌ | ❌ | ❌ |
| Downloadable offline frontend² | ✅ Open as local file | ❌ Needs PHP | ❌ Needs build | ❌ Needs build | ❌ Server-rendered |
| Tor Browser compatible | ✅ | ❌ | ❌ | ❌ | |
| i18n | ✅ 12 languages | ✅ Many | ❌ English only | ❌ English only | |
| Self-host: zero config | ✅ | ✅ | |||
| License | GPL-3.0 | zlib | AGPL-3.0 | Apache-2.0 | MIT |
¹ Burn-read protection means knowing the note ID alone is not sufficient to read the note — a second secret derived from the encryption key is also required. Without this, anyone who observes a note ID (e.g. from a server log) can burn the note before the intended recipient reads it.
² Downloadable offline frontend means you can save the static files locally and open them directly in a browser (including Tor Browser) without any server, build tool, or package manager. Use ?api=https://your-server to point your local copy at any backend.
Where SecNote trades off: state is volatile — notes are lost if the server restarts. There is no persistent backend; RAM-only storage is the threat model, not a limitation to work around.
cargo runNo configuration required — the API URL and privacy policy auto-detect from the page origin. The server starts on 0.0.0.0:443 (HTTPS) and 0.0.0.0:80 (redirect) and requires TLS certificates at startup.
Default TLS paths: /etc/letsencrypt/live/localhost/fullchain.pem and privkey.pem.
Override with TLS_CERT_PATH / TLS_KEY_PATH or copy .env.example → .env.
scripts/1click.sh gets a Let's Encrypt certificate and writes the paths into .env in one step.
sudo ./scripts/1click.sh example.com admin@example.com- Runs certbot in standalone mode (binds
:80temporarily — requires root and a free port 80). - Idempotent: skips certbot if the certificate files already exist.
- Creates
.envfrom.env.exampleif it doesn't exist yet, then setsTLS_CERT_PATHandTLS_KEY_PATHautomatically.
After the script finishes, start the server:
cargo run --releaseOptional env vars for the script:
| Variable | Default | Description |
|---|---|---|
LETSENCRYPT_DIR |
/etc/letsencrypt/live |
Base directory for certificate files |
LETSENCRYPT_STAGING |
0 |
Set to 1 to use Let's Encrypt staging CA (for testing) |
ENV_FILE |
<repo-root>/.env |
Path to the .env file to write |
The image handles everything — build, certificate, and server — in one command. No local clone required; Docker fetches the source directly from GitHub.
docker build -t secnote https://github.com/pwn-all/secure-notes.git
docker run -d \
--name secnote \
--restart unless-stopped \
-p 80:80 \
-p 443:443 \
-v letsencrypt:/etc/letsencrypt \
-e DOMAIN=example.com \
-e EMAIL=admin@example.com \
secnoteThe container runs certbot on first start, obtains a certificate, then starts the server. The /etc/letsencrypt volume persists the certificate across restarts — certbot skips renewal if the cert is still valid.
Set -e LETSENCRYPT_STAGING=1 to use the Let's Encrypt staging CA while testing.
If you already have a certificate (from certbot, another ACME client, or a CA):
docker run -d \
--name secnote \
--restart unless-stopped \
-p 80:80 \
-p 443:443 \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-e TLS_CERT_PATH=/etc/letsencrypt/live/example.com/fullchain.pem \
-e TLS_KEY_PATH=/etc/letsencrypt/live/example.com/privkey.pem \
secnoteNotes hold no state outside the process — there is no data volume to mount. Restarting the container clears all notes (by design).
All variables are optional. The server works with defaults.
| Variable | Default | Description |
|---|---|---|
HTTP_BIND_ADDR |
0.0.0.0:80 |
HTTP redirect listener |
HTTPS_BIND_ADDR |
0.0.0.0:443 |
HTTPS listener (BIND_ADDR is a legacy alias) |
PUBLIC_HOST |
(from Host header) |
Hostname used in HTTP→HTTPS redirects; inferred from the request Host header if not set |
TLS_CERT_PATH |
/etc/letsencrypt/live/localhost/fullchain.pem |
TLS certificate chain |
TLS_KEY_PATH |
/etc/letsencrypt/live/localhost/privkey.pem |
TLS private key |
CHALLENGE_TTL_SECS |
150 |
PoW challenge lifetime |
POW_BITS_CREATE |
17 |
Base PoW difficulty for note creation (alias: POW_BITS) |
POW_BITS_CREATE_MAX |
28 |
Max PoW difficulty under load (alias: POW_BITS_MAX) |
POW_BITS_VIEW |
16 |
Base PoW difficulty for note reading |
POW_BITS_VIEW_MAX |
24 |
Max PoW difficulty for reading under load |
MAX_PLAINTEXT_BYTES |
4096 |
Maximum plaintext size |
MAX_BLOB_BYTES |
16384 |
Maximum encrypted blob size |
POW_FAIL_WINDOW_SECS |
600 |
Window for counting PoW failures per IP |
BAN_SHORT_SECS |
300 |
Short ban (3+ failures) |
BAN_MEDIUM_SECS |
1800 |
Medium ban (6+ failures) |
BAN_LONG_SECS |
43200 |
Long ban (10+ failures) |
SIGNING_KEY |
(random at startup) | Base64url-encoded 32-byte Ed25519 signing key seed. Set this to keep the public key stable across restarts so pinned clients don't need to re-trust after a redeploy |
CLEANUP_INTERVAL_SECS |
30 |
Expired entry cleanup interval |
RATE_INIT_PER_MIN |
30 |
/api/v1/init rate limit per IP |
RATE_CREATE_PER_MIN |
30 |
Note creation rate limit per IP |
RATE_VIEW_PER_MIN |
60 |
Note reading rate limit per IP |
Base URL is the server's own origin; no API key required. All write operations require a solved PoW challenge.
Every response from /info and all /api/v1/* endpoints carries an Ed25519 signature:
x-secnote-sig: <base64url(Ed25519 signature of the raw response body bytes)>
The server's Ed25519 public key is returned by GET /info as pubkey (base64url, 32 bytes). The signing key is generated ephemerally at startup — it changes on every restart. The official frontend fetches and caches this key on first connect, then verifies every subsequent response before parsing — meaning a network-level attacker who can intercept TLS (e.g. a corporate proxy with a trusted CA cert) still cannot inject or tamper with API responses.
Because the signing key is ephemeral, the client must learn the server's current public key before it can verify responses. The trust flow works as follows:
- First connect — the client fetches
GET /infowithout signature verification (allowUnsigned: true) to retrievepubkey. This is the only unverified request the client ever makes. - TOFU storage — the fetched key is saved in
localStoragekeyed by API origin. All subsequent requests to that origin are verified against the stored key before any response body is parsed. - Pre-pinning — if you obtained the public key out-of-band (e.g. from the server's startup log), you can supply it as
<api-url>|<base64url-pubkey>in the?api=query parameter or in the settings panel. The client then skips the TOFU round-trip and trusts only that key from the start. - Stable key across restarts — by default the key is regenerated on every restart, which invalidates stored trust. Set the
SIGNING_KEYenvironment variable (base64url, 32-byte Ed25519 seed) to keep the public key constant so pinned clients don't need to re-trust after a redeploy.
Why this matters: TLS alone cannot protect you if an attacker controls a trusted CA (e.g. a corporate proxy or a nation-state MitM). Ed25519-signed responses mean that even a forged TLS certificate cannot produce valid signatures — the client will reject tampered or injected responses outright.
Returns a PoW challenge, encryption parameters, and server limits.
{
"ok": true,
"server_time": 1700000000,
"pow": {
"scope": "create",
"alg": "sha256-leading-zero-bits",
"bits": 22,
"expires_at": 1700000150,
"challenge": "<base64url>"
},
"encryption": { "alg": "aes-256-gcm", "key_bytes": 32, "nonce_bytes": 12, "tag_bytes": 16 },
"limits": { "max_plaintext_bytes": 4096, "max_blob_bytes": 16384, "ttls": [43200, 86400] }
}{
"alg": "aes-256-gcm",
"challenge": "<base64url>",
"nonce": "<base64url(pow_nonce)>",
"ttl": 43200,
"blob": "<base64url(iv ‖ ciphertext ‖ tag)>",
"view_token": "<base64url(SHA-256(aes_key_bytes))>"
}Response: { "ok": true, "nid": "<base64url>", "expires_at": 1700086400 }
{
"challenge": "<base64url>",
"nonce": "<base64url(pow_nonce)>",
"view_token": "<base64url(SHA-256(aes_key_bytes))>"
}Response: { "ok": true, "blob": "<base64url(iv ‖ ciphertext ‖ tag)>", "deleted": true }
Gone/expired: 410 with { "ok": false, "error": { "code": "gone", "message": "note is gone" } }
{ "ok": true, "notes": 42, "ram_usage": "2 mb", "pubkey": "<base64url>" } — anonymous aggregate plus the server's Ed25519 public key. The client fetches this endpoint on first connect to obtain the key for signature verification (see Public key trust model above).
Static files served from website/. The Rust backend handles only /api/v1/*, /info, and /.well-known/api-catalog; everything else is served by ServeDir.
| File | Purpose |
|---|---|
index.html |
App shell |
app-config.js |
Empty runtime config — API URL auto-detects from location.origin |
app.js |
Client logic: encryption, PoW, i18n, privacy policy rendering |
pow-worker.js |
PoW solver (Web Worker) |
styles.css |
Styles |
sw.js |
Service worker (offline shell cache) |
manifest.json |
PWA manifest |
langs/*.json |
UI translations (12 languages) |
The frontend auto-connects to the API at its own origin — no configuration needed. Use ?api=https://other-host to point at a different backend.
Privacy Policy and contact email are rendered client-side from location.host, so self-hosted instances get the correct operator information without any setup.
- Clone the repo and build:
cargo build --release - Get a TLS certificate for your domain (see above)
- Set
TLS_CERT_PATH/TLS_KEY_PATHpointing to your cert, then run the binary - Open your domain — the UI connects to your API automatically
The Privacy Policy shown to users will automatically display your domain and legal@yourdomain as the contact. Update the policy text in website/index.html if needed to reflect your jurisdiction.
cargo testqrcode-svg — MIT License. Copyright and license notice retained in this repository.
