Skip to content

Security: felipap/contexter

Security

SECURITY.md

This is a SHORT document, explaining the security measures in place. It's not the place for AI slop, or legaleeze.

Table of Contents

Authentication

There are two auth flows: one for the admin dashboard, one for devices (Electron app).

Admin Dashboard

A passphrase-based system. The passphrase is set via DASHBOARD_SECRET env var on the server.

  1. User enters passphrase on login page
  2. Server compares it to DASHBOARD_SECRET
  3. If valid, sets an httpOnly cookie (context_admin) containing the passphrase
  4. Cookie settings: httpOnly, secure in production, sameSite=lax, 1 year expiry
  5. Subsequent requests check if cookie value matches DASHBOARD_SECRET

In development, if DASHBOARD_SECRET is unset, auth is bypassed entirely.

Device Auth (Desktop App)

Devices authenticate via a shared secret:

  1. Set API_WRITE_SECRET env var on the server
  2. Enter the same secret in the desktop app settings
  3. Desktop app sends it as Authorization: Bearer <secret> header
  4. Server rejects requests where the token doesn't match

If API_WRITE_SECRET is unset on the server, device auth is bypassed (for development).

Server security

API read is actually more sensitive than write.

End-to-End Encryption (E2EE)

Messages and screenshots are encrypted on the desktop before upload. The encryption key never leaves your browser.

  • Desktop app encrypts data locally before sending to the server
  • Server stores encrypted blobs (it cannot read your data)
  • Web dashboard downloads encrypted data and decrypts it locally
  • The encryption key is stored in browser sessionStorage (must not be sent anywhere)
  • Decryption uses the Web Crypto API (crypto.subtle) in your browser

The server has no way to decrypt your data. If you lose your encryption key, your data is unrecoverable.

Encryption Key Management

What we do now:

  • Single encryption key per user (passphrase-derived via PBKDF2)
  • Encrypted values are prefixed with enc:v1: to self-identify as encrypted
  • Some fields are encrypted (message text, lat/long), others are plaintext (timestamps, contact IDs) so the server can index/query
  • No key identifier stored — we assume all data uses the same key
  • The v1 in enc:v1: indicates the encryption format version, not which key was used

What we could do (but don't):

  • Key rotation with key IDs: Format could become enc:v1:key01:iv:tag:ciphertext to track which key encrypted what. Would allow rotating keys without re-encrypting old data.
  • Per-table or per-field keys: Different keys for messages vs locations. More complexity, unclear benefit.
  • Key escrow / recovery: Store an encrypted backup of the key somewhere. Defeats zero-knowledge if done wrong.

Trade-offs of zero-knowledge encryption:

  • Forget your key → data is permanently lost (feature, not a bug)
  • Change your key → must re-encrypt everything
  • Multiple devices → all need the same passphrase
  • Server can't help you recover anything

Open questions Felipe doesn't know the answer to:

  • Is PBKDF2 with 100k iterations still considered good? (Argon2 is "better" but more complex)
  • Should we ever implement key rotation, or is "re-encrypt everything" fine for a personal tool?
  • Do we need to encrypt more fields? (e.g., contact phone numbers are currently plaintext for querying)

Rate Limiting

Configure rate limiting in the Vercel dashboard under Firewall → + New Rule.

Recommended rule for API endpoints:

  • Name: api-rate-limit
  • If: Request Path starts with /api
  • Rate Limit: Fixed Window, 300 seconds, 10 requests, Key: IP Address
  • Then: Too Many Requests (429)

See docs/firewall-example.png for reference.

There aren’t any published security advisories