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) or Argon2id, chosen at creation
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 the KDF selected at creation time: PBKDF2-SHA256 (600 000 iterations) or Argon2id (see ADR 0002). The server never derives keys; it stores the KDF name so the viewer knows which one to run. 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, so 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")