Skip to content

Encryption

Ghostbit uses true end-to-end encryption: all encryption and decryption happens in the client (browser or CLI). The server stores ciphertext only and can never read paste content.


Algorithm

Component Value
Cipher AES-256-GCM
Key size 256 bits
Nonce 12 bytes (random, per paste)
Auth tag 128 bits (included in ciphertext)
KDF (password) PBKDF2-SHA256, 600 000 iterations
KDF salt 16 bytes (random, per paste)

Key management

A random 256-bit key is generated by crypto.subtle.generateKey() (browser) or os.urandom(32) (CLI).

The key is never sent to the server. It lives exclusively in the URL fragment:

https://your-instance.com/aB3kZx9m#KEY_B64URL~DELETE_TOKEN

The #fragment is never transmitted in HTTP requests — it stays in the browser.

The key is derived from the user's password using PBKDF2-SHA256 (600 000 iterations). A random 16-byte salt is generated per paste and stored server-side (it is not secret).

The URL fragment contains only the delete token:

https://your-instance.com/aB3kZx9m#~DELETE_TOKEN

The password is never sent to the server.


Fragment format

KEY_B64URL~DELETE_TOKEN     ← no password
~DELETE_TOKEN               ← password-protected
  • KEY_B64URL — base64url-encoded AES-256-GCM key (no padding)
  • DELETE_TOKEN — raw delete token (shown once, used to enable the delete button)

The server stores a SHA-256 hash of the delete token, never the raw value.


Threat model

Threat Protected?
Server compromise Yes — server only has ciphertext
Database leak Yes — ciphertext without the key is useless
Network interception (HTTPS) Yes — key is in fragment, never transmitted
Malicious server operator Yes — server can't decrypt
URL shared with wrong person No — the key is in the URL
Weak password Depends on the user

Secure Context

crypto.subtle requires a Secure Context (HTTPS or localhost). Ghostbit automatically redirects 127.0.0.1localhost for local development.


CLI encryption

The CLI (gbit) mirrors the browser encryption using Python's cryptography library:

  • AESGCM for AES-256-GCM
  • PBKDF2HMAC with SHA-256 for password derivation
  • os.urandom for key and nonce generation

The ciphertext format is identical — pastes created by the CLI can be decrypted in the browser and vice versa.


Webhook signatures

When WEBHOOK_SECRET is configured, every webhook delivery is signed with HMAC-SHA256 and carries a timestamp header:

X-Ghostbit-Signature: sha256=<hex>
X-Ghostbit-Webhook-Timestamp: <unix-seconds>

The signature is computed over the raw JSON request body. The timestamp header is always present (even without WEBHOOK_SECRET) and mirrors payload.timestamp — verifiers can use it for replay protection without having to parse the body first. A typical receiver rejects deliveries older than 5 minutes.

To verify on the receiving end:

1
2
3
4
5
6
7
import hmac, hashlib

def verify(secret: str, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)
const crypto = require("crypto");

function verify(secret, body, header) {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(header)
  );
}

Warning

Always use a constant-time comparison (hmac.compare_digest, timingSafeEqual) to prevent timing attacks.

Replay protection

Even with a valid signature, an attacker who once intercepted a delivery could replay it. Reject any request whose X-Ghostbit-Webhook-Timestamp differs from the current time by more than ~5 minutes:

1
2
3
4
import time
drift = abs(int(time.time()) - int(headers["X-Ghostbit-Webhook-Timestamp"]))
if drift > 300:
    raise ValueError("stale delivery — refusing")