The dashboard uses a passphrase-based system. The passphrase is set via
DASHBOARD_SECRET env var on the server. It's stored in an httpOnly cookie that
expires in a year.
The Electron app authenticates via a shared secret API_WRITE_SECRET, sent as
Bearer token.
The user can use the dashboard to create access tokens for reading data from the API via the dashboard.
Data is encrypted on the client before it's sent to the server. The server API returns encrypted data too.
Users can see decrypted data in the dashboard by entering the encryption key, or
clicking the "Open Dashboard" button in the Electron app. Decryption happens
entirely in the user browser and the encryption key must never be sent to the
server. The encryption key is stored in sessionStorage and expires after 1 hour.
Each server-side table has a mix of encrypted fields (stored as enc:v1:...
ciphertext) and plaintext fields which the server can read.
The encryption makes it impossible to search for plaintext values, which would be a dealbreaker for certain workflows (eg. searching for messages from a particular phone number). To solve this, we also store HMAC-based blind indexes on certain encrypted fields, as noted below.
screenshots
| Fields | |
|---|---|
| Encrypted | image data |
| Plaintext | dimensions (width, height), file size |
imessages
| Fields | |
|---|---|
| Encrypted | text, subject |
| Plaintext | contact identifier, date, isFromMe, isRead, isSent, isDelivered, hasAttachments, service, chatId, chatName |
imessage_attachments
| Fields | |
|---|---|
| Encrypted | file data |
| Plaintext | filename, mimeType, file size |
contacts
| Fields | |
|---|---|
| Encrypted | firstName, lastName, organization, emails, phoneNumbers |
| Plaintext | contactId (source system ID) |
locations
| Fields | |
|---|---|
| Encrypted | latitude, longitude |
| Plaintext | timestamp, accuracy |
whatsapp_messages
| Fields | |
|---|---|
| Encrypted | text, chatName, senderName, senderPhoneNumber |
| Plaintext | chatId, messageId, senderJid, timestamp, isFromMe, isGroupChat |
| Search Indices | chatNameIndex, senderNameIndex, senderPhoneNumberIndex |
The remaining tables (write_logs, read_logs, access_tokens,
login_attempts) are operational — they don't store user content and have no
encrypted fields.
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
v1inenc: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:ciphertextto 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?
The encryption uses random IVs, so the same plaintext encrypted twice produces different ciphertexts. This is a security feature (prevents pattern detection) but makes server-side search impossible.
Solution: HMAC blind indexes
For fields that need to be searchable, we store a deterministic HMAC alongside the encrypted value:
senderPhoneNumber: "enc:v1:..." // encrypted (different every time)
senderPhoneNumberIndex: "a3f8c2..." // HMAC (same input → same output)
The HMAC is computed using:
- HMAC-SHA256
- Key derived via PBKDF2 with a separate salt (
contexter-search-index-v1) - Same passphrase as encryption
How search works:
- Client computes HMAC of search term using their passphrase
- Client sends the HMAC to the server (not the plaintext)
- Server matches against the index column
Limitations:
- Only exact matches work (no partial/fuzzy search)
- Phone numbers must be normalized consistently on both sides
- Adding a searchable field requires schema change + backfill from desktop app
Currently indexed fields (WhatsApp):
chatNameIndexsenderNameIndexsenderPhoneNumberIndex
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.
When self-hosting, rate limiting is not built into the application. Use a reverse proxy like nginx or Caddy in front of the app container to add rate limiting.