Security and privacy

How Evergist keeps notes unreadable, including to us

Every claim on this page can be checked from your own browser or terminal. Here's the design, what the server holds, what it never sees, and where the limits are.

Updated ยท Markdown

The short version

  • Your device encrypts the note with AES-256-GCM before anything is uploaded.
  • The 256-bit key lives in the link after the #. Browsers don't send that part of a URL to any server, so we never receive it.
  • The server stores the ciphertext, an expiry time, a view counter, and two token hashes. That's the whole record.
  • We don't store IP addresses, browser details, cookies, or referrers. Request logging is off.
  • Notes are deleted at their expiry time or right after their last allowed view.
  • Lose the link and the note is gone. We can't recover it, and neither can anyone who breaks into our servers.

What happens when you create a note

  1. Your browser generates a random 32-byte key, K, with the Web Crypto API.
  2. If you set a password, the browser stretches it with PBKDF2-SHA256 (600,000 iterations) and mixes it into the key material.
  3. It derives an encryption key and two access tokens from that material with HKDF-SHA256.
  4. It encrypts your text with AES-256-GCM under a fresh random 12-byte IV.
  5. It uploads the IV, the ciphertext, the SHA-256 hashes of the tokens, and your expiry and view settings.
  6. The server replies with an ID. Your browser builds the link https://evergist.com/g/<id>#<K> and shows it to you.

The plaintext, K, and your password never leave your device. The server can't derive them from the hashes it keeps.

What happens when someone opens it

  1. Their browser reads K from the part after # and derives the access token.
  2. It asks for the note's status, sending the access token. Without that token, the server answers exactly as if the note didn't exist.
  3. If the note has a password, the browser derives a password proof from K and the password.
  4. It requests the ciphertext with the access token and, if needed, the proof. This request uses one view.
  5. It decrypts locally. If that was the last allowed view, the server deletes the note before responding.

Someone who knows only the note's ID, say from a proxy that saw the URL path, can't read the ciphertext, can't learn when it expires, and can't burn its views.

The encryption scheme

This is version 1 of the scheme, exactly as the code implements it.

K        = 32 random bytes                              (in the URL fragment)
pwSalt   = HKDF-SHA256(K, info="evergist/v1/pbkdf2-salt", L=16)
pwKey    = PBKDF2-HMAC-SHA256(NFC(password), pwSalt, 600000, L=32)
ikm      = K || pwKey        if a password is set
         = K                 otherwise
encKey   = HKDF-SHA256(ikm, info="evergist/v1/enc",      L=32)
access   = HKDF-SHA256(K,   info="evergist/v1/access",   L=32)
proof    = HKDF-SHA256(ikm, info="evergist/v1/password", L=32)   (password only)
iv       = 12 random bytes
ct       = AES-256-GCM(encKey, iv, plaintext, aad="evergist/v1")

HKDF uses an empty salt. All binary values travel as unpadded base64url. The server stores SHA-256(access) and SHA-256(proof) as hex, and compares them in constant time.

A few choices worth explaining:

  • The key is in the fragment, not the query string. Browsers strip everything after # from HTTP requests, and it doesn't appear in the Referer header either.
  • The access token gates the ciphertext. It's derived from K, so only someone holding the link can fetch the encrypted bytes. Offline attacks on the ciphertext need the link first.
  • The password proof is separate. A wrong access token costs nothing: guessing 256 bits isn't possible. A wrong password counts toward a limit of 10. On the tenth miss the note deletes itself, which stops online guessing.
  • The password salt comes from K. Each note gets a unique salt that the server never sees, so the server can't precompute anything against your password.
  • AES-GCM authenticates. If a single bit of the stored ciphertext changes, decryption fails loudly instead of showing altered text.

What the server stores

Each note is its own Durable Object on Cloudflare. Durable Objects handle one request at a time, which makes view counting exact: two people can't both get the last view. This is the complete stored record:

Field Example Purpose
body 4,213 bytes of ciphertext The encrypted note
iv q1w2e3r4t5y6u7i8 AES-GCM nonce, not secret
accessHash 9f86d081... SHA-256 of the access token
proofHash 60303ae2... or absent SHA-256 of the password proof
deleteHash 2c26b46b... SHA-256 of the creator's delete token
password, iterations true, 600000 Tells the reader to ask for a password
expiresAt 2026-10-06T12:00:00Z When the note is deleted
maxViews, viewsRemaining 1, 1 View limit
failedAttempts 0 Wrong passwords so far

There is no creator field, no IP address, no user agent, no timestamp of reads, and no index linking notes to each other or to anyone.

An alarm fires at expiresAt and wipes the record. A read that uses the last view wipes it immediately.

What we don't collect

  • No IP addresses in storage or logs. The Worker has Cloudflare's observability, invocation logs, and Logpush turned off.
  • No cookies. The site sets none, first party or third party.
  • No analytics scripts. No Google Analytics, no tag managers, no pixels, no session replay.
  • No third-party requests at all. Fonts and icons are served from evergist.com. The Content-Security-Policy header only allows the page to connect to evergist.com.

We keep three public counters per day: page loads, notes created, and notes read. They're plain integers with no identifiers attached. You can see them on the stats page.

What Cloudflare can see

We're honest about our host. Evergist runs on Cloudflare, and like any network provider Cloudflare handles your IP address to route the connection and to block attacks. The per-minute rate limiter uses your IP as an in-memory key for 60 seconds at the Cloudflare location that served you. The hourly limit on creating notes keeps a salted hash of your IP in memory for up to two hours, and the salt itself only exists in memory. Neither is ever written to storage. Cloudflare never sees the key in the fragment, your password, or the plaintext, because none of those are ever transmitted.

Verify it yourself

You don't have to trust this page. Here are four ways to check it.

1. Watch the network

Open your browser's developer tools, switch to the Network tab, and create a note. You'll see one request to /api/v1/gists. Its body contains iv, ciphertext, and a couple of hashes. Your text isn't there, and neither is anything that appears after # in the link you get back.

2. Read the code that runs

The site's JavaScript isn't minified. In developer tools, open the Sources tab and read the create and view scripts. The encryption code is the same library the command line tool uses.

3. Check the headers

Run this and look at content-security-policy:

curl -sI https://evergist.com/ | grep -i -E 'content-security-policy|set-cookie|referrer-policy'

connect-src 'self' means the page can't send data to any other domain. There's no set-cookie line.

4. Decrypt with code we didn't write

This Python script reads a note using nothing but the scheme above and the cryptography package. If it can decrypt your note, the design works as described. Download it.

import base64, hashlib, json, sys, unicodedata, urllib.request
from urllib.parse import urlsplit
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

b64d = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
b64e = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
hkdf = lambda ikm, info, n: HKDF(hashes.SHA256(), n, None, info.encode()).derive(ikm)

def get(url, token):
    headers = {"authorization": f"Bearer {token}", "user-agent": "evergist-verify/1"}
    req = urllib.request.Request(url, headers=headers)
    return json.load(urllib.request.urlopen(req))

link, password = sys.argv[1], (sys.argv[2] if len(sys.argv) > 2 else None)
parts = urlsplit(link)
api = f"{parts.scheme}://{parts.netloc}/api/v1/gists/{parts.path.rstrip('/').split('/')[-1]}"
key = b64d(parts.fragment)
access = b64e(hkdf(key, "evergist/v1/access", 32))
status = get(f"{api}/status", access)
ikm, token = key, access
if status["password"]:
    salt = hkdf(key, "evergist/v1/pbkdf2-salt", 16)
    pw = unicodedata.normalize("NFC", password).encode()
    ikm = key + hashlib.pbkdf2_hmac("sha256", pw, salt, status["iterations"], 32)
    token = f"{access}.{b64e(hkdf(ikm, 'evergist/v1/password', 32))}"
sealed = get(api, token)  # uses one view
aes = AESGCM(hkdf(ikm, "evergist/v1/enc", 32))
print(aes.decrypt(b64d(sealed["iv"]), b64d(sealed["ciphertext"]), b"evergist/v1").decode())

Limits and trade-offs

No design is perfect. These are the ones we know about.

  • You trust the page you load. Like every browser-based encryption tool, the JavaScript comes from our server. If our server were compromised, an attacker could serve altered code. The strict CSP, the lack of third-party scripts, and readable code make that detectable, not impossible. For the highest assurance, encrypt with the command line tool or the Python script above, which run code you can pin and inspect.
  • Anyone with the link can read the note. The link is the key. If you paste it into a chat that keeps history, the note is readable until it expires or runs out of views. Use a one-view limit and a short expiry, or add a password and send it separately.
  • Size isn't hidden. AES-GCM doesn't pad, so the ciphertext length reveals the plaintext length to within 16 bytes.
  • Your browser history keeps the link. The fragment is stored locally like any URL. Once a one-view note is read, the link is useless anyway.
  • Timing is visible to our host. Cloudflare can see that some IP connected at some time, as it can for any site it serves.

Reporting a vulnerability

Email contact@evergist.com. Our security.txt has the details. We'll reply quickly and credit you if you'd like.